AP1.12d: Plan-Outline-Knoten Arbeit mit Actions-Liste im Plan-Kontext.
Some checks failed
Deploy Development / deploy (push) Successful in 56s
Test Suite / pytest-backend (push) Failing after 1m48s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 56s
Test Suite / pytest-backend (push) Failing after 1m48s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Route /plan/work, Modal-Anlage, Links zu Objektseiten; Outline zeigt Zähler für Eingang und Arbeit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a99d4a8974
commit
4f2f801889
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.16.7-ap1.12c"
|
APP_VERSION = "0.16.8-ap1.12d"
|
||||||
DB_SCHEMA_VERSION = "015"
|
DB_SCHEMA_VERSION = "015"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ function AppRoutes() {
|
||||||
<Route path="structure" element={<MODE_ROUTE_COMPONENTS.planStructure />} />
|
<Route path="structure" element={<MODE_ROUTE_COMPONENTS.planStructure />} />
|
||||||
<Route path="gates" element={<MODE_ROUTE_COMPONENTS.planGates />} />
|
<Route path="gates" element={<MODE_ROUTE_COMPONENTS.planGates />} />
|
||||||
<Route path="inbox" element={<MODE_ROUTE_COMPONENTS.planInbox />} />
|
<Route path="inbox" element={<MODE_ROUTE_COMPONENTS.planInbox />} />
|
||||||
|
<Route path="work" element={<MODE_ROUTE_COMPONENTS.planWork />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/control" element={<ControlLayout />}>
|
<Route path="/control" element={<ControlLayout />}>
|
||||||
|
|
|
||||||
151
frontend/src/components/PlanActionsSection.jsx
Normal file
151
frontend/src/components/PlanActionsSection.jsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
|
import { PriorityBadge } from './PriorityBadge.jsx'
|
||||||
|
import { Modal } from './Modal.jsx'
|
||||||
|
import { ActionForm } from './ActionForm.jsx'
|
||||||
|
import { gateTitleById } from './GateSelect.jsx'
|
||||||
|
import { buildProjectPath, collectProjectSubtreeIds } from '../utils/projectTree.js'
|
||||||
|
import { actionPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
function formatProjectContext(projects, projectId) {
|
||||||
|
if (!projectId) return 'Direkt am Vorhaben'
|
||||||
|
const path = buildProjectPath(projects, projectId)
|
||||||
|
return path.map((p) => p.title).join(' → ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanActionsSection({
|
||||||
|
actions,
|
||||||
|
projects = [],
|
||||||
|
roadmapItems = [],
|
||||||
|
scopeProjectId = '',
|
||||||
|
hideDone,
|
||||||
|
onHideDoneChange,
|
||||||
|
canManage,
|
||||||
|
onCreateAction,
|
||||||
|
actors,
|
||||||
|
actorsLoading,
|
||||||
|
actorsError,
|
||||||
|
actorsUsedFallback,
|
||||||
|
onReloadActors,
|
||||||
|
busy,
|
||||||
|
}) {
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
|
const visibleActions = useMemo(() => {
|
||||||
|
let list = hideDone
|
||||||
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
|
: [...actions]
|
||||||
|
|
||||||
|
if (scopeProjectId) {
|
||||||
|
const subtreeIds = collectProjectSubtreeIds(projects, scopeProjectId)
|
||||||
|
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const projectA = a.project_id || ''
|
||||||
|
const projectB = b.project_id || ''
|
||||||
|
if (projectA !== projectB) {
|
||||||
|
if (!projectA) return -1
|
||||||
|
if (!projectB) return 1
|
||||||
|
return projectA.localeCompare(projectB)
|
||||||
|
}
|
||||||
|
return (a.title || '').localeCompare(b.title || '')
|
||||||
|
})
|
||||||
|
}, [actions, hideDone, scopeProjectId, projects])
|
||||||
|
|
||||||
|
async function handleCreate(payload) {
|
||||||
|
await onCreateAction(payload)
|
||||||
|
setShowCreate(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card plan-actions-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Arbeit</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Committete Arbeitspakete im Plan-Kontext — Detail und Ausführung über die
|
||||||
|
Objektseite.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="section-actions">
|
||||||
|
<label className="checkbox-label inline-filter">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideDone}
|
||||||
|
onChange={(e) => onHideDoneChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Erledigte ausblenden
|
||||||
|
</label>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
>
|
||||||
|
Arbeitspaket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeProjectId && (
|
||||||
|
<p className="muted plan-actions-section__scope-hint">
|
||||||
|
Gefiltert nach Projekt im Scope — Scope oben leeren für alle Arbeitspakete.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleActions.length === 0 && (
|
||||||
|
<EmptyState message="Noch keine Arbeitspakete — lege welche an oder wandle Backlog-Items um." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="item-list plan-actions-list">
|
||||||
|
{visibleActions.map((action) => (
|
||||||
|
<li key={action.id} className="list-item card-list-item plan-actions-list__item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<Link to={actionPath(action.id)} className="plan-actions-list__title">
|
||||||
|
<strong>{action.title}</strong>
|
||||||
|
</Link>
|
||||||
|
<p className="list-item-sub muted">
|
||||||
|
{formatProjectContext(projects, action.project_id)}
|
||||||
|
</p>
|
||||||
|
{action.roadmap_item_id && (
|
||||||
|
<p className="list-item-sub muted">
|
||||||
|
Gate: {gateTitleById(roadmapItems, action.roadmap_item_id)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action.description && (
|
||||||
|
<p className="list-item-desc">{action.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<StatusBadge kind="action" status={action.status} />
|
||||||
|
<PriorityBadge priority={action.priority} />
|
||||||
|
<Link to={actionPath(action.id)} className="btn btn-secondary btn-sm">
|
||||||
|
Öffnen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Modal open={showCreate} title="Arbeitspaket anlegen" onClose={() => setShowCreate(false)} size="lg">
|
||||||
|
<ActionForm
|
||||||
|
projects={projects}
|
||||||
|
roadmapItems={roadmapItems}
|
||||||
|
actors={actors}
|
||||||
|
actorsLoading={actorsLoading}
|
||||||
|
actorsError={actorsError}
|
||||||
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
|
onReloadActors={onReloadActors}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
onCancel={() => setShowCreate(false)}
|
||||||
|
busy={busy}
|
||||||
|
submitLabel="Anlegen"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listInitiativeActions } from '../api/initiatives.js'
|
||||||
|
import { listInitiativeBacklog } from '../api/backlog.js'
|
||||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
import {
|
import {
|
||||||
PLAN_OUTLINE_NODES,
|
PLAN_OUTLINE_NODES,
|
||||||
|
|
@ -9,6 +12,40 @@ export function PlanOutlineNav() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
||||||
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
||||||
|
const [counts, setCounts] = useState({ inbox: null, work: null })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initiativeId) {
|
||||||
|
setCounts({ inbox: null, work: null })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
Promise.all([
|
||||||
|
listInitiativeBacklog(initiativeId).catch(() => []),
|
||||||
|
listInitiativeActions(initiativeId).catch(() => []),
|
||||||
|
]).then(([backlog, actions]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCounts({
|
||||||
|
inbox: backlog.filter((item) => item.status !== 'converted').length,
|
||||||
|
work: actions.filter(
|
||||||
|
(action) => action.status !== 'done' && action.status !== 'discarded',
|
||||||
|
).length,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initiativeId, location.pathname])
|
||||||
|
|
||||||
|
function nodeLabel(node) {
|
||||||
|
if (node.key === 'inbox' && counts.inbox != null) {
|
||||||
|
return `${node.label} (${counts.inbox})`
|
||||||
|
}
|
||||||
|
if (node.key === 'work' && counts.work != null) {
|
||||||
|
return `${node.label} (${counts.work})`
|
||||||
|
}
|
||||||
|
return node.label
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="plan-outline-nav" aria-label="Plan-Outline">
|
<nav className="plan-outline-nav" aria-label="Plan-Outline">
|
||||||
|
|
@ -41,7 +78,7 @@ export function PlanOutlineNav() {
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
title={needsInitiative ? 'Vorhaben im Scope wählen' : node.hint}
|
title={needsInitiative ? 'Vorhaben im Scope wählen' : node.hint}
|
||||||
>
|
>
|
||||||
{node.label}
|
{nodeLabel(node)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
@ -57,7 +94,7 @@ export function PlanOutlineNav() {
|
||||||
(isActive ? ' plan-outline-nav__link--active' : '')
|
(isActive ? ' plan-outline-nav__link--active' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{node.label}
|
{nodeLabel(node)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
64
frontend/src/pages/modes/PlanWorkPage.jsx
Normal file
64
frontend/src/pages/modes/PlanWorkPage.jsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
|
import { PlanActionsSection } from '../../components/PlanActionsSection.jsx'
|
||||||
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||||
|
|
||||||
|
function PlanWorkInner() {
|
||||||
|
const ops = useInitiativeOperations()
|
||||||
|
const { projectId: scopeProjectId } = useProgramScope()
|
||||||
|
const {
|
||||||
|
actions,
|
||||||
|
projects,
|
||||||
|
roadmapItems,
|
||||||
|
capabilities,
|
||||||
|
hideDone,
|
||||||
|
setHideDone,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
formBusy,
|
||||||
|
actors,
|
||||||
|
actorsLoading,
|
||||||
|
actorsError,
|
||||||
|
actorsUsedFallback,
|
||||||
|
reloadActors,
|
||||||
|
handleCreateAction,
|
||||||
|
} = ops
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingState message="Lade Arbeitspakete …" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<PlanActionsSection
|
||||||
|
actions={actions}
|
||||||
|
projects={projects}
|
||||||
|
roadmapItems={roadmapItems}
|
||||||
|
scopeProjectId={scopeProjectId}
|
||||||
|
hideDone={hideDone}
|
||||||
|
onHideDoneChange={setHideDone}
|
||||||
|
canManage={capabilities.has('kairo.action.manage')}
|
||||||
|
onCreateAction={handleCreateAction}
|
||||||
|
actors={actors}
|
||||||
|
actorsLoading={actorsLoading}
|
||||||
|
actorsError={actorsError}
|
||||||
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
|
onReloadActors={reloadActors}
|
||||||
|
busy={formBusy}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanWorkPage() {
|
||||||
|
return (
|
||||||
|
<RequireInitiativeScope lead="Arbeitspakete gehören zu einem Vorhaben im Scope.">
|
||||||
|
<ScopedInitiativeProvider>
|
||||||
|
<PlanWorkInner />
|
||||||
|
</ScopedInitiativeProvider>
|
||||||
|
</RequireInitiativeScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,14 +11,7 @@ export const PLAN_OUTLINE_NODES = [
|
||||||
{ key: 'structure', to: '/plan/structure', label: 'Struktur', requiresInitiative: true },
|
{ key: 'structure', to: '/plan/structure', label: 'Struktur', requiresInitiative: true },
|
||||||
{ key: 'gates', to: '/plan/gates', label: 'Zielzustände', requiresInitiative: true },
|
{ key: 'gates', to: '/plan/gates', label: 'Zielzustände', requiresInitiative: true },
|
||||||
{ key: 'inbox', to: '/plan/inbox', label: 'Eingang', requiresInitiative: true },
|
{ key: 'inbox', to: '/plan/inbox', label: 'Eingang', requiresInitiative: true },
|
||||||
{
|
{ key: 'work', to: '/plan/work', label: 'Arbeit', requiresInitiative: true },
|
||||||
key: 'work',
|
|
||||||
to: '/plan/work',
|
|
||||||
label: 'Arbeit',
|
|
||||||
requiresInitiative: true,
|
|
||||||
disabled: true,
|
|
||||||
hint: 'AP1.12 — Actions im Plan-Kontext',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ describe('planOutlineNodes', () => {
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/structure')).toBe('structure')
|
expect(resolvePlanOutlineActiveKey('/plan/structure')).toBe('structure')
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/gates')).toBe('gates')
|
expect(resolvePlanOutlineActiveKey('/plan/gates')).toBe('gates')
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox')
|
expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox')
|
||||||
|
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx'
|
||||||
import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx'
|
import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx'
|
||||||
import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx'
|
import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx'
|
||||||
import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx'
|
import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx'
|
||||||
|
import { PlanWorkPage } from '../pages/modes/PlanWorkPage.jsx'
|
||||||
import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx'
|
import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx'
|
||||||
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
|
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
|
||||||
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
|
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
|
||||||
|
|
@ -132,6 +133,7 @@ export const MODE_ROUTE_COMPONENTS = {
|
||||||
planStructure: PlanStructurePage,
|
planStructure: PlanStructurePage,
|
||||||
planGates: PlanGatesPage,
|
planGates: PlanGatesPage,
|
||||||
planInbox: PlanInboxPage,
|
planInbox: PlanInboxPage,
|
||||||
|
planWork: PlanWorkPage,
|
||||||
controlStatus: ControlStatusPage,
|
controlStatus: ControlStatusPage,
|
||||||
controlJourney: ControlJourneyPage,
|
controlJourney: ControlJourneyPage,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -417,3 +417,16 @@
|
||||||
.plan-portfolio__hint {
|
.plan-portfolio__hint {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-actions-list__title {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-actions-list__title:hover strong {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-actions-section__scope-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user