fix: Sprint-AP Bearbeiten, Sprint-Verschiebung und klarere Aktionsleiste.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
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) Has been cancelled

Behebt editingActionId-Bug in Plan/Work-Sprint; Schnellzuweisung AP zu anderem Sprint ohne Voll-Reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-24 18:04:26 +02:00
parent 04d1dbf1f9
commit ee1e1c0671
9 changed files with 178 additions and 40 deletions

View File

@ -3,6 +3,7 @@ import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { ActorSelect } from './ActorSelect.jsx' import { ActorSelect } from './ActorSelect.jsx'
import { GateSelect } from './GateSelect.jsx' import { GateSelect } from './GateSelect.jsx'
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js' import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
import { PLANNING_SPRINT_STATUSES } from '../utils/workCycleActions.js'
function isoToLocalInput(iso) { function isoToLocalInput(iso) {
if (!iso) return '' if (!iso) return ''
@ -47,7 +48,9 @@ export function ActionForm({
const defaultActors = initial.assigned_actor_ids || [] const defaultActors = initial.assigned_actor_ids || []
const leafProjects = getLeafProjects(projects) const leafProjects = getLeafProjects(projects)
const projectOptions = flattenLeafProjectOptions(projects) const projectOptions = flattenLeafProjectOptions(projects)
const cycleOptions = workCycles.filter((c) => c.status !== 'completed') const cycleOptions = workCycles.filter(
(c) => PLANNING_SPRINT_STATUSES.has(c.status) || c.id === initial.work_cycle_id,
)
const defaultCycleId = const defaultCycleId =
initial.work_cycle_id || (activeWorkCycle?.id && !initial.id ? activeWorkCycle.id : '') initial.work_cycle_id || (activeWorkCycle?.id && !initial.id ? activeWorkCycle.id : '')
@ -108,13 +111,19 @@ export function ActionForm({
/> />
{cycleOptions.length > 0 && ( {cycleOptions.length > 0 && (
<label> <label>
Sprint (optional) Sprint
<select name="work_cycle_id" defaultValue={defaultCycleId}> <select name="work_cycle_id" defaultValue={defaultCycleId}>
<option value=""> kein Sprint </option> <option value=""> kein Sprint </option>
{cycleOptions.map((cycle) => ( {cycleOptions.map((cycle) => (
<option key={cycle.id} value={cycle.id}> <option key={cycle.id} value={cycle.id}>
{cycle.title} {cycle.title}
{cycle.status === 'active' ? ' (aktiv)' : ''} {cycle.status === 'active'
? ' (aktiv)'
: cycle.status === 'planned'
? ' (geplant)'
: cycle.status === 'reached'
? ' (abgeschlossen)'
: ''}
</option> </option>
))} ))}
</select> </select>

View File

@ -5,6 +5,7 @@ import { PriorityBadge } from './PriorityBadge.jsx'
import { ActionForm } from './ActionForm.jsx' import { ActionForm } from './ActionForm.jsx'
import { gateTitleById } from './GateSelect.jsx' import { gateTitleById } from './GateSelect.jsx'
import { actionTitleById } from '../utils/actionReferences.js' import { actionTitleById } from '../utils/actionReferences.js'
import { SprintTransferSelect } from './SprintTransferSelect.jsx'
function formatDue(iso) { function formatDue(iso) {
if (!iso) return null if (!iso) return null
@ -45,6 +46,7 @@ export function ActionHubCard({
onQuickStatus, onQuickStatus,
onCreateBlocker, onCreateBlocker,
onUnplanToBacklog, onUnplanToBacklog,
onMoveToSprint,
canManage, canManage,
canManageBlocker, canManageBlocker,
formBusy, formBusy,
@ -123,6 +125,14 @@ export function ActionHubCard({
{action.assigned_actor_ids?.length > 0 && ( {action.assigned_actor_ids?.length > 0 && (
<span>{action.assigned_actor_ids.length} zugewiesen</span> <span>{action.assigned_actor_ids.length} zugewiesen</span>
)} )}
{action.work_cycle_id && (
<span>
Sprint:{' '}
{workCycles.find((c) => c.id === action.work_cycle_id)?.title ||
activeWorkCycle?.title ||
'…'}
</span>
)}
{due && <span>Fällig: {due}</span>} {due && <span>Fällig: {due}</span>}
</div> </div>
</header> </header>
@ -169,6 +179,7 @@ export function ActionHubCard({
{canManage && ( {canManage && (
<footer className="action-hub-card-actions"> <footer className="action-hub-card-actions">
<div className="action-hub-card-actions__primary">
<select <select
className="inline-select" className="inline-select"
value={action.status} value={action.status}
@ -181,6 +192,16 @@ export function ActionHubCard({
</option> </option>
))} ))}
</select> </select>
{typeof onMoveToSprint === 'function' && (
<SprintTransferSelect
action={action}
workCycles={workCycles}
busy={formBusy}
onMove={onMoveToSprint}
/>
)}
</div>
<div className="action-hub-card-actions__secondary">
{canManageBlocker && action.status !== 'done' && ( {canManageBlocker && action.status !== 'done' && (
<button <button
type="button" type="button"
@ -216,6 +237,7 @@ export function ActionHubCard({
Details Details
</Link> </Link>
)} )}
</div>
</footer> </footer>
)} )}
</article> </article>

View File

@ -22,6 +22,7 @@ export function InitiativeActionsHub({
onQuickStatus, onQuickStatus,
onCreateBlockerForAction, onCreateBlockerForAction,
onUnplanToBacklog, onUnplanToBacklog,
onMoveToSprint,
canManage, canManage,
canManageBlocker, canManageBlocker,
formBusy, formBusy,
@ -32,6 +33,7 @@ export function InitiativeActionsHub({
onReloadActors, onReloadActors,
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 = [],
}) { }) {
return ( return (
<section className="card initiative-actions-hub"> <section className="card initiative-actions-hub">
@ -98,6 +100,7 @@ export function InitiativeActionsHub({
onQuickStatus={(status) => onQuickStatus(action, status)} onQuickStatus={(status) => onQuickStatus(action, status)}
onCreateBlocker={() => onCreateBlockerForAction(action.id)} onCreateBlocker={() => onCreateBlockerForAction(action.id)}
onUnplanToBacklog={onUnplanToBacklog} onUnplanToBacklog={onUnplanToBacklog}
onMoveToSprint={onMoveToSprint}
canManage={canManage} canManage={canManage}
canManageBlocker={canManageBlocker} canManageBlocker={canManageBlocker}
formBusy={formBusy} formBusy={formBusy}
@ -111,7 +114,7 @@ export function InitiativeActionsHub({
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
workCycles={workCycles} workCycles={workCycles}
activeWorkCycle={activeWorkCycle} activeWorkCycle={activeWorkCycle}
allActions={actions} allActions={allActions.length ? allActions : actions}
/> />
))} ))}
</div> </div>

View File

@ -0,0 +1,46 @@
import { listCarryoverTargetSprints } from '../utils/workCycleActions.js'
/**
* Schnellzuweisung: AP in einen anderen Sprint verschieben.
*/
export function SprintTransferSelect({
action,
workCycles = [],
busy = false,
onMove,
className = '',
}) {
if (!action?.work_cycle_id || action.status === 'done' || action.status === 'discarded') {
return null
}
const targets = listCarryoverTargetSprints(workCycles, action.work_cycle_id)
if (targets.length === 0) return null
return (
<label className={`sprint-transfer-select ${className}`.trim()}>
<span className="sr-only">In anderen Sprint verschieben</span>
<select
className="inline-select"
value=""
disabled={busy}
aria-label={`${action.title} in anderen Sprint verschieben`}
onChange={(e) => {
const targetId = e.target.value
if (targetId && typeof onMove === 'function') {
onMove(action, targetId)
}
e.target.value = ''
}}
>
<option value=""> Sprint </option>
{targets.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{cycle.title}
{cycle.status === 'active' ? ' (aktiv)' : cycle.status === 'planned' ? ' (geplant)' : ''}
</option>
))}
</select>
</label>
)
}

View File

@ -118,7 +118,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false) const [showActionForm, setShowActionForm] = useState(false)
const [editingAction, setEditingAction] = useState(null) const [editingActionId, setEditingActionId] = useState(null)
const [formBusy, setFormBusy] = useState(false) const [formBusy, setFormBusy] = useState(false)
const [hideDone, setHideDone] = useState(true) const [hideDone, setHideDone] = useState(true)
const [selectedWorkCycleId, setSelectedWorkCycleId] = useState('') const [selectedWorkCycleId, setSelectedWorkCycleId] = useState('')
@ -364,7 +364,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
if (capabilities.has('kairo.action.manage')) { if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || []) await setActionAssignments(actionId, payload.assigned_actor_ids || [])
} }
setEditingAction(null) setEditingActionId(null)
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -373,6 +373,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
} }
} }
async function handleMoveActionToSprint(action, targetCycleId) {
if (!action?.id || !targetCycleId || action.work_cycle_id === targetCycleId) return
setFormBusy(true)
setError(null)
try {
const updated = await updateAction(action.id, { work_cycle_id: targetCycleId })
setActions((prev) => prev.map((a) => (a.id === action.id ? updated : a)))
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleQuickStatus(action, status) { async function handleQuickStatus(action, status) {
if (!capabilities.has('kairo.action.manage')) return if (!capabilities.has('kairo.action.manage')) return
try { try {
@ -966,8 +980,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
error, error,
showActionForm, showActionForm,
setShowActionForm, setShowActionForm,
editingAction, editingActionId,
setEditingAction, setEditingActionId,
formBusy, formBusy,
hideDone, hideDone,
setHideDone, setHideDone,
@ -983,6 +997,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleMethodProfileChange, handleMethodProfileChange,
handleCreateAction, handleCreateAction,
handleUpdateAction, handleUpdateAction,
handleMoveActionToSprint,
handleQuickStatus, handleQuickStatus,
handleCreateBlocker, handleCreateBlocker,
handleCreateBlockerForAction, handleCreateBlockerForAction,

View File

@ -17,8 +17,8 @@ export function InitiativeExecutionPage() {
setHideDone, setHideDone,
showActionForm, showActionForm,
setShowActionForm, setShowActionForm,
editingAction, editingActionId,
setEditingAction, setEditingActionId,
capabilities, capabilities,
formBusy, formBusy,
unlinkedBlockers, unlinkedBlockers,
@ -63,11 +63,11 @@ export function InitiativeExecutionPage() {
showForm={showActionForm} showForm={showActionForm}
onToggleForm={() => { onToggleForm={() => {
setShowActionForm((v) => !v) setShowActionForm((v) => !v)
setEditingAction(null) setEditingActionId(null)
}} }}
editingActionId={editingAction} editingActionId={editingActionId}
onEditAction={setEditingAction} onEditAction={setEditingActionId}
onCancelEdit={() => setEditingAction(null)} onCancelEdit={() => setEditingActionId(null)}
onCreateAction={ops.handleCreateAction} onCreateAction={ops.handleCreateAction}
onUpdateAction={ops.handleUpdateAction} onUpdateAction={ops.handleUpdateAction}
onQuickStatus={ops.handleQuickStatus} onQuickStatus={ops.handleQuickStatus}

View File

@ -28,8 +28,8 @@ function PlanSprintInner() {
formBusy, formBusy,
showActionForm, showActionForm,
setShowActionForm, setShowActionForm,
editingAction, editingActionId,
setEditingAction, setEditingActionId,
actionContextById, actionContextById,
actors, actors,
actorsLoading, actorsLoading,
@ -45,6 +45,7 @@ function PlanSprintInner() {
handleUpdateAction, handleUpdateAction,
handleQuickStatus, handleQuickStatus,
handleCreateBlockerForAction, handleCreateBlockerForAction,
handleMoveActionToSprint,
handleUnplanAction, handleUnplanAction,
} = ops } = ops
@ -102,14 +103,15 @@ function PlanSprintInner() {
onHideDoneChange={setHideDone} onHideDoneChange={setHideDone}
showForm={showActionForm} showForm={showActionForm}
onToggleForm={() => setShowActionForm((v) => !v)} onToggleForm={() => setShowActionForm((v) => !v)}
editingActionId={editingAction?.id} editingActionId={editingActionId}
onEditAction={setEditingAction} onEditAction={setEditingActionId}
onCancelEdit={() => setEditingAction(null)} onCancelEdit={() => setEditingActionId(null)}
onCreateAction={createSprintAction} onCreateAction={createSprintAction}
onUpdateAction={handleUpdateAction} onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus} onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction} onCreateBlockerForAction={handleCreateBlockerForAction}
onUnplanToBacklog={handleUnplanAction} onUnplanToBacklog={handleUnplanAction}
onMoveToSprint={handleMoveActionToSprint}
canManage={capabilities.has('kairo.action.manage')} canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')} canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy} formBusy={formBusy}
@ -120,6 +122,7 @@ function PlanSprintInner() {
onReloadActors={reloadActors} onReloadActors={reloadActors}
workCycles={workCycles} workCycles={workCycles}
activeWorkCycle={selectedWorkCycle} activeWorkCycle={selectedWorkCycle}
allActions={actions}
sectionTitle={`Sprint-Backlog — ${selectedWorkCycle.title}`} sectionTitle={`Sprint-Backlog — ${selectedWorkCycle.title}`}
sectionLead={sprintLead} sectionLead={sprintLead}
/> />

View File

@ -26,8 +26,8 @@ function WorkSprintInner() {
formBusy, formBusy,
showActionForm, showActionForm,
setShowActionForm, setShowActionForm,
editingAction, editingActionId,
setEditingAction, setEditingActionId,
actionContextById, actionContextById,
actors, actors,
actorsLoading, actorsLoading,
@ -38,6 +38,8 @@ function WorkSprintInner() {
handleUpdateAction, handleUpdateAction,
handleQuickStatus, handleQuickStatus,
handleCreateBlockerForAction, handleCreateBlockerForAction,
handleMoveActionToSprint,
handleUnplanAction,
initiative, initiative,
steeringSnapshot, steeringSnapshot,
steeringSnapshotLoading, steeringSnapshotLoading,
@ -101,13 +103,15 @@ function WorkSprintInner() {
onHideDoneChange={setHideDone} onHideDoneChange={setHideDone}
showForm={showActionForm} showForm={showActionForm}
onToggleForm={() => setShowActionForm((v) => !v)} onToggleForm={() => setShowActionForm((v) => !v)}
editingActionId={editingAction?.id} editingActionId={editingActionId}
onEditAction={setEditingAction} onEditAction={setEditingActionId}
onCancelEdit={() => setEditingAction(null)} onCancelEdit={() => setEditingActionId(null)}
onCreateAction={handleCreateAction} onCreateAction={handleCreateAction}
onUpdateAction={handleUpdateAction} onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus} onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction} onCreateBlockerForAction={handleCreateBlockerForAction}
onMoveToSprint={handleMoveActionToSprint}
onUnplanToBacklog={handleUnplanAction}
canManage={capabilities.has('kairo.action.manage')} canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')} canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy} formBusy={formBusy}
@ -159,13 +163,15 @@ function WorkSprintInner() {
onHideDoneChange={setHideDone} onHideDoneChange={setHideDone}
showForm={showActionForm} showForm={showActionForm}
onToggleForm={() => setShowActionForm((v) => !v)} onToggleForm={() => setShowActionForm((v) => !v)}
editingActionId={editingAction?.id} editingActionId={editingActionId}
onEditAction={setEditingAction} onEditAction={setEditingActionId}
onCancelEdit={() => setEditingAction(null)} onCancelEdit={() => setEditingActionId(null)}
onCreateAction={createAction} onCreateAction={createAction}
onUpdateAction={handleUpdateAction} onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus} onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction} onCreateBlockerForAction={handleCreateBlockerForAction}
onMoveToSprint={handleMoveActionToSprint}
onUnplanToBacklog={handleUnplanAction}
canManage={capabilities.has('kairo.action.manage')} canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')} canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy} formBusy={formBusy}
@ -176,9 +182,14 @@ function WorkSprintInner() {
onReloadActors={reloadActors} onReloadActors={reloadActors}
workCycles={ops.workCycles} workCycles={ops.workCycles}
activeWorkCycle={activeWorkCycle} activeWorkCycle={activeWorkCycle}
allActions={ops.actions}
/> />
</> </>
)} )}
{!activeWorkCycle && !isProductArchetype && (
<EmptyState message="Sprint-Ausführung ist für Product-Vorhaben — wähle ein passendes Vorhaben im Scope." />
)}
</> </>
) )
} }

View File

@ -999,13 +999,42 @@
.action-hub-card-actions { .action-hub-card-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.75rem; margin-top: 0.75rem;
padding-top: 0.65rem; padding-top: 0.65rem;
border-top: 1px solid var(--jk-border, #e5e7eb); border-top: 1px solid var(--jk-border, #e5e7eb);
} }
.action-hub-card-actions__primary,
.action-hub-card-actions__secondary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.action-hub-card-actions__primary {
padding-bottom: 0.35rem;
border-bottom: 1px dashed var(--jk-border, #e5e7eb);
}
.sprint-transfer-select .inline-select {
min-width: 9rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.btn-sm { .btn-sm {
padding: 0.25rem 0.55rem; padding: 0.25rem 0.55rem;
font-size: 0.8rem; font-size: 0.8rem;