Compare commits

...

2 Commits

Author SHA1 Message Date
252b3f05f2 feat(AP1.9d, AP2.2d): Work-Default-UI, Sprint-Planung und Composition Work-Slots
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:05:15 +02:00
89ae15ca4c chore(seeds): Dogfooding-Seed explizit dev-only (.dev.py) und MIGRATIONS-Doku
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:04:52 +02:00
22 changed files with 657 additions and 247 deletions

View File

@ -51,8 +51,25 @@ docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py --
| `seed_001_cleanup_pytest_artifacts` | SQL (dev) | Entfernt pytest/CI-User (`*@example.com`), verwaiste Test-Tenants; löscht zuerst Initiatives (AP0.5 FK) | | `seed_001_cleanup_pytest_artifacts` | SQL (dev) | Entfernt pytest/CI-User (`*@example.com`), verwaiste Test-Tenants; löscht zuerst Initiatives (AP0.5 FK) |
| `seed_002_bootstrap_admin` | Python | Legt Systemadmin aus `KAIRO_BOOTSTRAP_*` an, wenn noch kein User existiert | | `seed_002_bootstrap_admin` | Python | Legt Systemadmin aus `KAIRO_BOOTSTRAP_*` an, wenn noch kein User existiert |
| `seed_003_ensure_dev_admin` | Python (dev) | Entfernt fremde Dev-User, stellt `lars@stommer.com` als Portal-Admin sicher (jeder Start) | | `seed_003_ensure_dev_admin` | Python (dev) | Entfernt fremde Dev-User, stellt `lars@stommer.com` als Portal-Admin sicher (jeder Start) |
| `seed_004_dogfooding_kairo_jinkendo` | Python (dev) | Deprecated — Dogfooding-Visualisierung, **nicht** Produkt; nur Nicht-Prod |
Dev-Seeds (`.dev.*`) werden in Nicht-Prod-Umgebungen **bei jedem Backend-Start** erneut ausgeführt. Dev-Seeds (`.dev.*` im Dateinamen) laufen **nicht in Production** (`ENVIRONMENT=production`) und werden in Dev **bei jedem Backend-Start** erneut ausgeführt.
### Prod-taugliche Seeds (laufen auch auf main)
| Seed | Verhalten Prod |
|------|----------------|
| `seed_002_bootstrap_admin` | Legt Admin **nur** an, wenn DB leer **und** `KAIRO_BOOTSTRAP_*` in `.env` gesetzt |
### Code-Registry (kein Data-Seed — identisch Dev/Prod)
Beim Backend-Start (`entrypoint.sh`, überspringbar via `SKIP_REGISTRY_SYNC=1`):
- `sync_rights_registry.py` — Capabilities
- `sync_prompt_feature_config.py` — Features/Prompts
- `sync_entity_field_registry.py` — Archetypen + Felddefinitionen aus `entity_archetypes/registry.py`
Zusätzlich im Code (Deploy-Artifact, keine DB-Seed-Datei): `method_profiles/registry.py`, `steering/methods/registrations/*`, Starter-Kits in `archetype_starter_kit.py`.
## Neuen Seed anlegen ## Neuen Seed anlegen

View File

@ -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.inbox.proposals` | plan | inbox | Triage-Vorschläge |
| `plan.sprint.proposals` | plan | sprint | Sprint-Commit-Vorschläge | | `plan.sprint.proposals` | plan | sprint | Sprint-Commit-Vorschläge |
| `plan.gates.proposals` | plan | gates | Gate-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.
--- ---

View File

@ -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) | | Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
| AP2.2b | A2 Linear E2E ✓ (2026-07-27) | | AP2.2b | A2 Linear E2E ✓ (2026-07-27) |
| AP2.2c | A1 Reifegrad E2E ✓ (2026-07-27) | | AP2.2c | A1 Reifegrad E2E ✓ (2026-07-27) |
| AP1.9d | Work-Composition + Drill-down ◐→✓ (2026-07-27) |
| AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ | | AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität | | AP1.7b | Op-API Parität |

View File

@ -78,7 +78,7 @@ Phase 0 DOC + Kurskorrektur (dieses Dokument) ✓
Phase 0b AP2.3/AP2.4 Plugin-Architektur (Archetyp ↔ Methode) ✓ Phase 0b AP2.3/AP2.4 Plugin-Architektur (Archetyp ↔ Methode) ✓
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓ Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓
Phase 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐ 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 ✓ Phase 3 AP2.2b A2 Linear End-to-End ✓
AP2.2c A1 Reifegrad + AP2.0e ✓ AP2.2c A1 Reifegrad + AP2.0e ✓
AP2.2d B2b Product + B3 Sprint ✓ AP2.2d B2b Product + B3 Sprint ✓

View File

@ -42,6 +42,8 @@ export function BacklogSection({
busy, busy,
sectionTitle = 'Product Backlog', sectionTitle = 'Product Backlog',
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.', sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
defaultSprintTargetId = null,
lockSprintTarget = false,
}) { }) {
const { operatingContext, steeringElements, steeringSnapshot } = useInitiativeOperations() const { operatingContext, steeringElements, steeringSnapshot } = useInitiativeOperations()
const [modalMode, setModalMode] = useState(null) const [modalMode, setModalMode] = useState(null)
@ -74,6 +76,13 @@ export function BacklogSection({
if (!showSprintPlanning || !initiativeId) { if (!showSprintPlanning || !initiativeId) {
return return
} }
if (
defaultSprintTargetId
&& planableSprints.some((cycle) => cycle.id === defaultSprintTargetId)
) {
setSprintTargetId(defaultSprintTargetId)
return
}
const stored = sessionStorage.getItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId) const stored = sessionStorage.getItem(SPRINT_TARGET_STORAGE_PREFIX + initiativeId)
if (stored && planableSprints.some((cycle) => cycle.id === stored)) { if (stored && planableSprints.some((cycle) => cycle.id === stored)) {
setSprintTargetId(stored) setSprintTargetId(stored)
@ -84,7 +93,13 @@ export function BacklogSection({
} }
const preferred = activeWorkCycle?.id || planableSprints[0]?.id || '' const preferred = activeWorkCycle?.id || planableSprints[0]?.id || ''
setSprintTargetId(preferred) setSprintTargetId(preferred)
}, [showSprintPlanning, initiativeId, planableSprints, activeWorkCycle?.id]) }, [
showSprintPlanning,
initiativeId,
planableSprints,
activeWorkCycle?.id,
defaultSprintTargetId,
])
useEffect(() => { useEffect(() => {
if (!initiativeId || !sprintTargetId) return if (!initiativeId || !sprintTargetId) return
@ -292,21 +307,36 @@ export function BacklogSection({
{showSprintPlanning && canManage && ( {showSprintPlanning && canManage && (
<div className="backlog-sprint-planning card-list-item"> <div className="backlog-sprint-planning card-list-item">
<label className="backlog-sprint-planning__field"> {lockSprintTarget && selectedSprint ? (
<span className="muted">Ziel-Sprint</span> <p className="muted backlog-sprint-planning__locked">
<select Ziel-Sprint: <strong>{selectedSprint.title}</strong>
value={sprintTargetId} {selectedSprint.status === 'active'
onChange={(e) => setSprintTargetId(e.target.value)} ? ' (aktiv)'
disabled={busy} : selectedSprint.status === 'planned'
> ? ' (geplant)'
{planableSprints.map((cycle) => ( : ''}
<option key={cycle.id} value={cycle.id}> </p>
{cycle.title} ) : (
{cycle.status === 'active' ? ' (aktiv)' : cycle.status === 'planned' ? ' (geplant)' : ''} <label className="backlog-sprint-planning__field">
</option> <span className="muted">Ziel-Sprint</span>
))} <select
</select> value={sprintTargetId}
</label> onChange={(e) => setSprintTargetId(e.target.value)}
disabled={busy}
>
{planableSprints.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{cycle.title}
{cycle.status === 'active'
? ' (aktiv)'
: cycle.status === 'planned'
? ' (geplant)'
: ''}
</option>
))}
</select>
</label>
)}
{selectedCommittable.length > 0 && ( {selectedCommittable.length > 0 && (
<button <button
type="button" type="button"

View File

@ -1,29 +1,55 @@
import { useState } from 'react' import { useState } from 'react'
/**
* Generischer Drill-down-Container AP1.9d Anti-Todo-Wand.
* Bewusst layout-neutral für späteres UI-Redesign.
*/
export function CollapsibleSection({ export function CollapsibleSection({
title, title,
summary, lead,
defaultOpen = false, defaultCollapsed = true,
expandLabel = 'Alle anzeigen',
collapseLabel = 'Einklappen',
headerActions = null,
className = 'card',
children, children,
className = '',
}) { }) {
const [open, setOpen] = useState(defaultOpen) const [expanded, setExpanded] = useState(!defaultCollapsed)
return ( return (
<section className={`collapsible-section card ${className}`.trim()}> <section className={className}>
<button <div className="section-header">
type="button" <div>
className="collapsible-section-trigger" {title && <h2>{title}</h2>}
onClick={() => setOpen((v) => !v)} {lead && <p className="section-lead muted">{lead}</p>}
aria-expanded={open} </div>
> {headerActions}
<span className="collapsible-section-title">{title}</span> </div>
{summary && <span className="collapsible-section-summary muted">{summary}</span>}
<span className="collapsible-section-chevron" aria-hidden="true"> {expanded ? (
{open ? '▾' : '▸'} <>
</span> {children}
</button> <p className="collapsible-section__toggle">
{open && <div className="collapsible-section-body">{children}</div>} <button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setExpanded(false)}
>
{collapseLabel}
</button>
</p>
</>
) : (
<p className="collapsible-section__toggle">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setExpanded(true)}
>
{expandLabel}
</button>
</p>
)}
</section> </section>
) )
} }

View File

@ -1,5 +1,6 @@
import { ActionHubCard } from './ActionHubCard.jsx' import { ActionHubCard } from './ActionHubCard.jsx'
import { ActionForm } from './ActionForm.jsx' import { ActionForm } from './ActionForm.jsx'
import { CollapsibleSection } from './CollapsibleSection.jsx'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
export function InitiativeActionsHub({ export function InitiativeActionsHub({
@ -34,35 +35,35 @@ export function InitiativeActionsHub({
sectionTitle = 'Arbeitspakete', sectionTitle = 'Arbeitspakete',
sectionLead = 'Committete operative Einheit — Aufgaben im Detail. Blocker, Nachweise und Reviews hängen am Arbeitspaket.', sectionLead = 'Committete operative Einheit — Aufgaben im Detail. Blocker, Nachweise und Reviews hängen am Arbeitspaket.',
allActions = [], allActions = [],
collapsible = false,
defaultCollapsed = true,
expandLabel = 'Alle anzeigen',
collapseLabel = 'Liste einklappen',
}) { }) {
return ( const headerActions = (
<section className="card initiative-actions-hub"> <div className="section-actions">
<div className="section-header"> <label className="checkbox-label inline-filter">
<div> <input
<h2>{sectionTitle}</h2> type="checkbox"
<p className="section-lead muted">{sectionLead}</p> checked={hideDone}
</div> onChange={(e) => onHideDoneChange(e.target.checked)}
<div className="section-actions"> />
<label className="checkbox-label inline-filter"> Erledigte ausblenden
<input </label>
type="checkbox" {canManage && (
checked={hideDone} <button
onChange={(e) => onHideDoneChange(e.target.checked)} type="button"
/> className="btn btn-primary btn-block-mobile"
Erledigte ausblenden onClick={onToggleForm}
</label> >
{canManage && ( {showForm ? 'Abbrechen' : 'Arbeitspaket'}
<button </button>
type="button" )}
className="btn btn-primary btn-block-mobile" </div>
onClick={onToggleForm} )
>
{showForm ? 'Abbrechen' : 'Arbeitspaket'}
</button>
)}
</div>
</div>
const body = (
<>
{showForm && canManage && ( {showForm && canManage && (
<div className="inline-form-block"> <div className="inline-form-block">
<ActionForm <ActionForm
@ -118,6 +119,35 @@ export function InitiativeActionsHub({
/> />
))} ))}
</div> </div>
</>
)
if (collapsible) {
return (
<CollapsibleSection
className="card initiative-actions-hub"
title={sectionTitle}
lead={sectionLead}
defaultCollapsed={defaultCollapsed}
expandLabel={expandLabel}
collapseLabel={collapseLabel}
headerActions={headerActions}
>
{body}
</CollapsibleSection>
)
}
return (
<section className="card initiative-actions-hub">
<div className="section-header">
<div>
<h2>{sectionTitle}</h2>
<p className="section-lead muted">{sectionLead}</p>
</div>
{headerActions}
</div>
{body}
</section> </section>
) )
} }

View File

@ -33,6 +33,9 @@ export function InitiativeCompositionSurface({
handleMethodChange: ops.handleMethodChange, handleMethodChange: ops.handleMethodChange,
methodBusy: ops.methodBusy, methodBusy: ops.methodBusy,
formBusy: ops.formBusy, formBusy: ops.formBusy,
activeWorkCycle: ops.activeWorkCycle,
uiFeatures: ops.uiFeatures,
dataSlices: ops.dataSlices,
onAcceptBacklogProposal, onAcceptBacklogProposal,
}), }),
[ [
@ -46,6 +49,9 @@ export function InitiativeCompositionSurface({
ops.handleMethodChange, ops.handleMethodChange,
ops.methodBusy, ops.methodBusy,
ops.formBusy, ops.formBusy,
ops.activeWorkCycle,
ops.uiFeatures,
ops.dataSlices,
onAcceptBacklogProposal, onAcceptBacklogProposal,
], ],
) )

View File

@ -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 (
<EmptyState
message={`${PRODUCT_TERMS.sprintBacklog} ist für diesen Vorhabentyp nicht verfügbar.`}
/>
)
}
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 (
<InitiativeActionsHub
initiativeId={initiativeId}
actions={panelActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => 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}
/>
)
}

View File

@ -22,6 +22,7 @@ import { WIDGETS } from '../registry/widgetRegistry.js'
* @property {string} [proposalKey] * @property {string} [proposalKey]
* @property {string} [widgetKey] * @property {string} [widgetKey]
* @property {string} [predicateKey] * @property {string} [predicateKey]
* @property {string} [workActionsVariant]
*/ */
const WIDGET_PROVIDERS = WIDGETS.filter((w) => w.area === 'workspace').map((widget) => ({ const WIDGET_PROVIDERS = WIDGETS.filter((w) => w.area === 'workspace').map((widget) => ({
@ -81,7 +82,7 @@ export const COMPOSITION_PROVIDERS = [
key: 'steering.next_action', key: 'steering.next_action',
kind: 'steering_element', kind: 'steering_element',
steeringElement: 'next_action_primary', steeringElement: 'next_action_primary',
slotKeys: ['control.status.steering'], slotKeys: ['control.status.steering', 'work.today.primary', 'work.sprint.primary'],
componentKey: 'NextActionWidget', componentKey: 'NextActionWidget',
requiresCapability: 'kairo.workspace.read', requiresCapability: 'kairo.workspace.read',
scopeTypes: ['initiative'], scopeTypes: ['initiative'],
@ -137,6 +138,40 @@ export const COMPOSITION_PROVIDERS = [
scopeTypes: ['initiative'], scopeTypes: ['initiative'],
order: 0, 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,
},
] ]
/** /**

View File

@ -7,6 +7,7 @@ import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx' import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx' import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
import { NextActionWidget } from '../widgets/NextActionWidget.jsx' import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
import { WorkActionsPanel } from './WorkActionsPanel.jsx'
/** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */ /** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */
export function RoadblockersStrip({ initiativeId, actionsBlocked = 0, blockersOpen = 0 }) { export function RoadblockersStrip({ initiativeId, actionsBlocked = 0, blockersOpen = 0 }) {
@ -43,6 +44,7 @@ export const PROVIDER_COMPONENTS = {
SteeringProposalsPanel, SteeringProposalsPanel,
RoadblockersStrip, RoadblockersStrip,
WidgetHost, WidgetHost,
WorkActionsPanel,
} }
/** /**

View File

@ -33,6 +33,25 @@ const CONDITIONAL_PREDICATES = {
const counts = ctx.steeringSnapshot?.counts || {} const counts = ctx.steeringSnapshot?.counts || {}
return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0 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) const widget = getWidgetByKey(provider.widgetKey)
return { widget } return { widget }
} }
case 'WorkActionsPanel':
return {
variant: provider.workActionsVariant || 'today',
defaultCollapsed: true,
}
default: default:
return {} return {}
} }

View File

@ -18,6 +18,8 @@ describe('uiSlotRegistry', () => {
it('defines MVP surfaces', () => { it('defines MVP surfaces', () => {
expect(COMPOSITION_SURFACES['control.status']).toBeTruthy() expect(COMPOSITION_SURFACES['control.status']).toBeTruthy()
expect(COMPOSITION_SURFACES['plan.inbox']).toBeTruthy() expect(COMPOSITION_SURFACES['plan.inbox']).toBeTruthy()
expect(COMPOSITION_SURFACES['work.today']).toBeTruthy()
expect(COMPOSITION_SURFACES['work.sprint']).toBeTruthy()
expect(COMPOSITION_SURFACES.cockpit).toBeTruthy() expect(COMPOSITION_SURFACES.cockpit).toBeTruthy()
}) })
@ -258,4 +260,67 @@ describe('resolveSteeringComposition', () => {
expect(withGate.showGateHorizon).toBe(true) expect(withGate.showGateHorizon).toBe(true)
expect(withoutGate.showGateHorizon).toBe(false) 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)
})
}) })

View File

@ -26,6 +26,11 @@ export const UI_SLOTS = [
{ key: 'plan.inbox.proposals', mode: 'plan', routeKey: 'inbox', label: 'Triage-Vorschläge', order: 0 }, { 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.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: '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', routeKey: 'gates',
slotKeys: ['plan.gates.proposals'], 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'],
},
} }
/** /**

View File

@ -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}`
}

View File

@ -2,11 +2,13 @@ import { useEffect, useMemo, useCallback } from 'react'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx' import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx' import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
import { BacklogSection } from '../../components/BacklogSection.jsx'
import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx' import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { LoadingState } from '../../components/LoadingState.jsx' import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { countActionsByWorkCycle } from '../../utils/workCycleActions.js' import { countActionsByWorkCycle } from '../../utils/workCycleActions.js'
import { PRODUCT_TERMS } from '../../constants/productLanguage.js'
function PlanSprintInner() { function PlanSprintInner() {
const ops = useInitiativeOperations() const ops = useInitiativeOperations()
@ -19,8 +21,10 @@ function PlanSprintInner() {
selectedSprintActions, selectedSprintActions,
workCycles, workCycles,
actions, actions,
backlogItems,
projects, projects,
roadmapItems, roadmapItems,
featureParentActions,
capabilities, capabilities,
hideDone, hideDone,
setHideDone, setHideDone,
@ -49,19 +53,50 @@ function PlanSprintInner() {
handleMoveActionToSprint, handleMoveActionToSprint,
handleUnplanAction, handleUnplanAction,
handleConvertBacklog, handleConvertBacklog,
handleBulkConvertBacklog,
handleCreateBacklog,
handleUpdateBacklog,
handleReorderBacklog,
handleDeleteBacklog,
reloadSlices, reloadSlices,
} = ops } = ops
useEffect(() => { useEffect(() => {
reloadSlices(['steering_snapshot']) reloadSlices(['steering_snapshot', 'backlog'])
}, [reloadSlices])
const reloadAfterSprintCommit = useCallback(async () => {
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
}, [reloadSlices]) }, [reloadSlices])
const onAcceptBacklogProposal = useCallback( const onAcceptBacklogProposal = useCallback(
async (itemId, options) => { async (itemId, options) => {
await handleConvertBacklog(itemId, options) await handleConvertBacklog(itemId, {
await reloadSlices(['steering_snapshot', 'actions', 'backlog']) 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( const proposalFilterContext = useMemo(
@ -113,11 +148,33 @@ function PlanSprintInner() {
busy={formBusy} busy={formBusy}
/> />
{selectedWorkCycleId && ( {selectedWorkCycleId && (
<InitiativeCompositionSurface <>
surfaceKey="plan.sprint" <InitiativeCompositionSurface
filterContext={proposalFilterContext} surfaceKey="plan.sprint"
onAcceptBacklogProposal={onAcceptBacklogProposal} filterContext={proposalFilterContext}
/> onAcceptBacklogProposal={onAcceptBacklogProposal}
/>
<BacklogSection
items={backlogItems}
initiativeId={initiativeId}
roadmapItems={roadmapItems}
featureActions={featureParentActions}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdate={handleUpdateBacklog}
onReorder={handleReorderBacklog}
onConvert={onConvertToSprint}
onBulkConvert={onBulkConvertToSprint}
onDelete={handleDeleteBacklog}
busy={formBusy}
defaultSprintTargetId={selectedWorkCycleId}
lockSprintTarget
sectionTitle={`Aus ${PRODUCT_TERMS.intake} in ${PRODUCT_TERMS.workCycle} planen`}
sectionLead={`Markiere commit-bereite Items und lege sie in „${selectedWorkCycle?.title || PRODUCT_TERMS.workCycle}" — oder nutze die Sprint-Vorschläge oben.`}
/>
</>
)} )}
{selectedWorkCycleId && selectedWorkCycle && ( {selectedWorkCycleId && selectedWorkCycle && (
<InitiativeActionsHub <InitiativeActionsHub
@ -150,7 +207,7 @@ function PlanSprintInner() {
workCycles={workCycles} workCycles={workCycles}
activeWorkCycle={selectedWorkCycle} activeWorkCycle={selectedWorkCycle}
allActions={actions} allActions={actions}
sectionTitle={`Sprint-Backlog${selectedWorkCycle.title}`} sectionTitle={`${PRODUCT_TERMS.sprintBacklog}${selectedWorkCycle.title}`}
sectionLead={sprintLead} sectionLead={sprintLead}
/> />
)} )}

View File

@ -1,51 +1,26 @@
import { useEffect, useMemo } from 'react' import { useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx' import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
import { LoadingState } from '../../components/LoadingState.jsx' import { LoadingState } from '../../components/LoadingState.jsx'
import { EmptyState } from '../../components/EmptyState.jsx' import { EmptyState } from '../../components/EmptyState.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'
import { scopedPath } from '../../utils/routes.js' import { scopedPath } from '../../utils/routes.js'
import { PRODUCT_TERMS } from '../../constants/productLanguage.js'
function WorkSprintInner() { function WorkSprintInner() {
const { initiativeId, hrefWithScope } = useProgramScope() const { initiativeId, hrefWithScope } = useProgramScope()
const ops = useInitiativeOperations() const ops = useInitiativeOperations()
const { const {
activeWorkCycle, activeWorkCycle,
sprintActions,
projects,
roadmapItems,
capabilities,
hideDone,
setHideDone,
loading, loading,
error, error,
formBusy,
showActionForm,
setShowActionForm,
editingActionId,
setEditingActionId,
actionContextById,
actors,
actorsLoading,
actorsError,
actorsUsedFallback,
reloadActors,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
handleCreateBlockerForAction,
handleMoveActionToSprint,
handleUnplanAction,
initiative,
operatingContext, operatingContext,
dataSlices, dataSlices,
uiFeatures, uiFeatures,
steeringSnapshot, steeringSnapshot,
steeringSnapshotLoading, steeringSnapshotLoading,
visibleActions,
reloadSlices, reloadSlices,
} = ops } = ops
@ -65,157 +40,63 @@ function WorkSprintInner() {
reloadSlices, 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) { if (loading) {
return <LoadingState message="Lade Sprint-Backlog …" /> return <LoadingState message={`Lade ${PRODUCT_TERMS.sprintBacklog}`} />
} }
const isProductArchetype = isContinuousProductMode if (!supportsWorkCycles) {
return (
<EmptyState
message={`${PRODUCT_TERMS.sprintBacklog} ist für diesen Vorhabentyp nicht verfügbar.`}
/>
)
}
return ( return (
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{!activeWorkCycle && isProductArchetype && ( {!activeWorkCycle && isContinuousProductMode && (
<> <section className="card sprint-context-banner">
<section className="card sprint-context-banner"> <h2>Product-Ist (ohne aktiven {PRODUCT_TERMS.workCycle})</h2>
<h2>Product-Ist (ohne aktiven Sprint)</h2> <p className="section-lead muted">
<p className="section-lead muted"> Kein aktiver {PRODUCT_TERMS.workCycle} Kairo steuert nach{' '}
Kein aktiver Sprint Kairo steuert nach{' '} <strong>{operatingContext?.method_key || 'continuous_product'}</strong> (wirkungsvollster
<strong>{operatingContext?.method_key || 'continuous_product'}</strong> (wirkungsvollster Schritt). Sprint-Planung Schritt). {PRODUCT_TERMS.workCycle}-Planung unter{' '}
unter{' '} <Link to={hrefWithScope('/plan/sprint')} className="link-inline">
<Link to={hrefWithScope('/plan/sprint')} className="link-inline"> Plan Sprint planen
Plan Sprint </Link>
</Link> .
. </p>
</p> </section>
</section>
<NextActionWidget
scope="initiative"
initiativeId={initiativeId}
items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading}
embedded
title="Nächster Schritt (Product)"
subtitle="Empfehlung ohne Sprint-Zeitbox — committete Arbeitspakete im Vorhaben."
/>
<InitiativeActionsHub
initiativeId={initiativeId}
actions={visibleActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => 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 && ( {activeWorkCycle && (
<> <section className="card sprint-context-banner">
<section className="card sprint-context-banner"> <h2>{PRODUCT_TERMS.sprintBacklog}</h2>
<h2>Sprint-Backlog</h2> <p className="section-lead muted">
<p className="section-lead muted"> <strong>{activeWorkCycle.title}</strong>
<strong>{activeWorkCycle.title}</strong> {activeWorkCycle.goal_description && `${activeWorkCycle.goal_description}`}
{activeWorkCycle.goal_description && `${activeWorkCycle.goal_description}`} {' · '}
{' · '} {PRODUCT_TERMS.productBacklog} triagierst du unter{' '}
Product Backlog triagierst du unter{' '} <Link to={scopedPath('/plan/inbox', { initiativeId })} className="link-inline">
<Link to={scopedPath('/plan/inbox', { initiativeId })} className="link-inline"> Plan {PRODUCT_TERMS.intake}
Plan Eingang </Link>
</Link> .
. </p>
</p> </section>
</section>
<NextActionWidget
scope="initiative"
initiativeId={initiativeId}
items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading}
embedded
title="Nächster Schritt im Sprint"
/>
<InitiativeActionsHub
initiativeId={initiativeId}
actions={sprintActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => 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}
/>
</>
)} )}
{!activeWorkCycle && !supportsWorkCycles && ( <InitiativeCompositionSurface surfaceKey="work.sprint" />
<EmptyState message="Sprint-Ausführung ist für diesen Vorhabentyp nicht verfügbar." />
)}
</> </>
) )
} }
export function WorkSprintPage() { export function WorkSprintPage() {
return ( return (
<RequireInitiativeScope lead="Sprint-Backlog ist vorhabenspezifisch — wähle ein passendes Vorhaben im Scope."> <RequireInitiativeScope
lead={`${PRODUCT_TERMS.sprintBacklog} ist vorhabenspezifisch — wähle ein passendes Vorhaben im Scope.`}
>
<WorkSprintInner /> <WorkSprintInner />
</RequireInitiativeScope> </RequireInitiativeScope>
) )

View File

@ -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 { 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 ( return (
<section className="card"> <>
<WorkspaceTodayWidget /> <NextActionWidget
</section> scope="portfolio"
embedded
title="Nächste sinnvolle Schritte"
subtitle="Portfolio-weit — wähle ein Vorhaben im Scope für methodengebundene Ausführung."
/>
<section className="card">
<WorkspaceTodayWidget limit={3} />
</section>
</>
) )
} }
function WorkTodayInitiativeInner() {
const { hrefWithScope } = useProgramScope()
const ops = useInitiativeOperations()
const { loading, error, reloadSlices, uiFeatures } = ops
useEffect(() => {
reloadSlices(['steering_snapshot'])
}, [reloadSlices])
if (loading) {
return <LoadingState message="Lade Ausführung …" />
}
const isProductWithSprint = uiFeatures.continuousProductWorkMode
return (
<>
{error && <p className="error">{error}</p>}
{isProductWithSprint && (
<section className="card work-context-banner">
<p className="section-lead muted">
Ausführen zeigt den <strong>nächsten Schritt</strong> nicht die Gesamtliste.{' '}
{PRODUCT_TERMS.sprintBacklog} unter{' '}
<Link to={hrefWithScope('/work/sprint')} className="link-inline">
Ausführen Sprint
</Link>
.
</p>
</section>
)}
<InitiativeCompositionSurface surfaceKey="work.today" />
</>
)
}
function WorkTodayInitiativeView() {
return (
<RequireInitiativeScope lead="Ausführung im Vorhaben-Kontext braucht ein gewähltes Vorhaben im Scope.">
<WorkTodayInitiativeInner />
</RequireInitiativeScope>
)
}
export function WorkTodayPage() {
const { initiativeId } = useProgramScope()
if (!initiativeId) {
return <WorkTodayPortfolioView />
}
return <WorkTodayInitiativeView />
}

View File

@ -39,7 +39,7 @@ export const STEERING_ELEMENT_UI = {
/** Labels für Steuerungselemente auf Plan-Profil (Anzeige, nicht Verhalten). */ /** Labels für Steuerungselemente auf Plan-Profil (Anzeige, nicht Verhalten). */
export const STEERING_ELEMENT_PLAN_LABELS = { export const STEERING_ELEMENT_PLAN_LABELS = {
work_cycle_scope: 'Sprint-Zeitbox', work_cycle_scope: 'Sprint',
backlog_epic_hierarchy: 'Epic-Hierarchie im Eingang', backlog_epic_hierarchy: 'Epic-Hierarchie im Eingang',
gate_fulfillment: 'Gate-Fortschritt', gate_fulfillment: 'Gate-Fortschritt',
queue_inbox: 'Queue-Eingang', queue_inbox: 'Queue-Eingang',

View File

@ -1093,6 +1093,11 @@
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
} }
.collapsible-section__toggle {
margin: 0.75rem 0 0;
padding: 0 1rem 1rem;
}
.initiative-advanced-stack { .initiative-advanced-stack {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;

View File

@ -8,6 +8,7 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx' import { WidgetCard } from '../components/WidgetCard.jsx'
import { actionPath, scopedPath } from '../utils/routes.js' import { actionPath, scopedPath } from '../utils/routes.js'
import { PRODUCT_TERMS } from '../constants/productLanguage.js'
function formatDue(iso) { function formatDue(iso) {
if (!iso) return null if (!iso) return null
@ -23,7 +24,7 @@ function formatDue(iso) {
} }
} }
export function WorkspaceTodayWidget() { export function WorkspaceTodayWidget({ limit = 10 }) {
const [actions, setActions] = useState([]) const [actions, setActions] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -32,32 +33,34 @@ export function WorkspaceTodayWidget() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await listWorkspaceTodayActions(10) const data = await listWorkspaceTodayActions(limit)
setActions(Array.isArray(data) ? data : []) setActions(Array.isArray(data) ? data : [])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [limit])
useEffect(() => { useEffect(() => {
load() load()
}, [load]) }, [load])
const hasMoreHint = limit < 10
return ( return (
<WidgetCard <WidgetCard
title="Heute" title="Heute"
subtitle="Deine Maßnahmen — überfällig und dringend zuerst" subtitle={`Deine ${PRODUCT_TERMS.actionPlural} — überfällig und dringend zuerst (max. ${limit})`}
actions={ actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}> <button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren Aktualisieren
</button> </button>
} }
footer={ footer={
actions.length > 0 ? ( actions.length > 0 || hasMoreHint ? (
<Link to="/work/mine" className="link-inline"> <Link to="/work/mine" className="link-inline">
Alle meine Maßnahmen Alle {PRODUCT_TERMS.actionPossessive}
</Link> </Link>
) : null ) : null
} }
@ -65,7 +68,7 @@ export function WorkspaceTodayWidget() {
{loading && <LoadingState />} {loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />} {!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && actions.length === 0 && ( {!loading && !error && actions.length === 0 && (
<EmptyState message="Keine Maßnahmen für heute — prüfe „Nächste sinnvolle Schritte“ oben." /> <EmptyState message={`Keine ${PRODUCT_TERMS.actionPlural} für heute — wähle ein Vorhaben für die Steuerungsempfehlung.`} />
)} )}
{!loading && !error && actions.length > 0 && ( {!loading && !error && actions.length > 0 && (
<ul className="item-list"> <ul className="item-list">