AP1.12b/d: Gates per Modal und Actions in der Plan-Outline.
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 46s
Test Suite / pytest-backend (push) Has been cancelled
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 46s
Test Suite / pytest-backend (push) Has been cancelled
RoadmapPlanSection ist read-only mit Modal-Anlage; Stammdaten auf der Gate-Detailseite. Unter Arbeit zeigt die Outline offene Actions wie der Projektbaum unter Struktur. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bcf6f83813
commit
b1686f3a98
|
|
@ -5,11 +5,12 @@ import { listInitiativeActions } from '../api/initiatives.js'
|
||||||
import { listInitiativeBacklog } from '../api/backlog.js'
|
import { listInitiativeBacklog } from '../api/backlog.js'
|
||||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
import { buildProjectsByParent } from '../utils/projectTree.js'
|
import { buildProjectsByParent } from '../utils/projectTree.js'
|
||||||
import { projectPath } from '../utils/routes.js'
|
import { projectPath, actionPath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
PLAN_OUTLINE_NODES,
|
PLAN_OUTLINE_NODES,
|
||||||
resolvePlanOutlineActiveKey,
|
resolvePlanOutlineActiveKey,
|
||||||
resolvePlanOutlineProjectId,
|
resolvePlanOutlineProjectId,
|
||||||
|
resolvePlanOutlineActionId,
|
||||||
} from '../plan/planOutlineNodes.js'
|
} from '../plan/planOutlineNodes.js'
|
||||||
|
|
||||||
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
||||||
|
|
@ -52,16 +53,25 @@ export function PlanOutlineNav() {
|
||||||
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
||||||
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
||||||
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
||||||
|
const activeActionId = resolvePlanOutlineActionId(location.pathname)
|
||||||
const [counts, setCounts] = useState({ inbox: null, work: null })
|
const [counts, setCounts] = useState({ inbox: null, work: null })
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
|
const [openActions, setOpenActions] = useState([])
|
||||||
const [structureOpen, setStructureOpen] = useState(
|
const [structureOpen, setStructureOpen] = useState(
|
||||||
activeKey === 'structure' || Boolean(activeProjectId),
|
activeKey === 'structure' || Boolean(activeProjectId),
|
||||||
)
|
)
|
||||||
|
const [workOpen, setWorkOpen] = useState(
|
||||||
|
activeKey === 'work' || Boolean(activeActionId),
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
|
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
|
||||||
}, [activeKey, activeProjectId])
|
}, [activeKey, activeProjectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWorkOpen(activeKey === 'work' || Boolean(activeActionId))
|
||||||
|
}, [activeKey, activeActionId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
setProjects([])
|
setProjects([])
|
||||||
|
|
@ -91,11 +101,13 @@ export function PlanOutlineNav() {
|
||||||
listInitiativeActions(initiativeId).catch(() => []),
|
listInitiativeActions(initiativeId).catch(() => []),
|
||||||
]).then(([backlog, actions]) => {
|
]).then(([backlog, actions]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
const open = actions.filter(
|
||||||
|
(action) => action.status !== 'done' && action.status !== 'discarded',
|
||||||
|
)
|
||||||
|
setOpenActions(open)
|
||||||
setCounts({
|
setCounts({
|
||||||
inbox: backlog.filter((item) => item.status !== 'converted').length,
|
inbox: backlog.filter((item) => item.status !== 'converted').length,
|
||||||
work: actions.filter(
|
work: open.length,
|
||||||
(action) => action.status !== 'done' && action.status !== 'discarded',
|
|
||||||
).length,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -136,7 +148,9 @@ export function PlanOutlineNav() {
|
||||||
const needsInitiative = node.requiresInitiative && !initiativeId
|
const needsInitiative = node.requiresInitiative && !initiativeId
|
||||||
const isDisabled = node.disabled || needsInitiative
|
const isDisabled = node.disabled || needsInitiative
|
||||||
const isStructure = node.key === 'structure'
|
const isStructure = node.key === 'structure'
|
||||||
|
const isWork = node.key === 'work'
|
||||||
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
||||||
|
const hasActionList = isWork && openActions.length > 0 && initiativeId
|
||||||
|
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -169,12 +183,25 @@ export function PlanOutlineNav() {
|
||||||
{structureOpen ? '▾' : '▸'}
|
{structureOpen ? '▾' : '▸'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{hasActionList && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="analysis-split__nav-toggle"
|
||||||
|
aria-expanded={workOpen}
|
||||||
|
aria-label={workOpen ? 'Arbeit einklappen' : 'Arbeit ausklappen'}
|
||||||
|
onClick={() => setWorkOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{workOpen ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<NavLink
|
<NavLink
|
||||||
to={hrefWithScope(node.to)}
|
to={hrefWithScope(node.to)}
|
||||||
end
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
'analysis-split__nav-link' +
|
'analysis-split__nav-link' +
|
||||||
(isActive || (isStructure && activeProjectId)
|
(isActive ||
|
||||||
|
(isStructure && activeProjectId) ||
|
||||||
|
(isWork && activeActionId)
|
||||||
? ' analysis-split__nav-link--active'
|
? ' analysis-split__nav-link--active'
|
||||||
: '')
|
: '')
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +216,25 @@ export function PlanOutlineNav() {
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasActionList && workOpen && (
|
||||||
|
<ul className="analysis-split__nav-sublist">
|
||||||
|
{openActions.map((action) => (
|
||||||
|
<li key={action.id}>
|
||||||
|
<NavLink
|
||||||
|
to={actionPath(action.id)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
'analysis-split__nav-link analysis-split__nav-link--nested' +
|
||||||
|
(isActive || activeActionId === action.id
|
||||||
|
? ' analysis-split__nav-link--active'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{action.title}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
124
frontend/src/components/RoadmapItemForm.jsx
Normal file
124
frontend/src/components/RoadmapItemForm.jsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import {
|
||||||
|
MILESTONE_STATUSES,
|
||||||
|
MILESTONE_STATUS_LABELS,
|
||||||
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
|
SEQUENCING_MODE_LABELS,
|
||||||
|
} from '../constants/status.js'
|
||||||
|
|
||||||
|
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
||||||
|
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
|
||||||
|
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
||||||
|
(s) => !['reached', 'moved', 'discarded'].includes(s),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function RoadmapItemForm({
|
||||||
|
initial = {},
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
busy = false,
|
||||||
|
submitLabel = 'Anlegen',
|
||||||
|
mode = 'create',
|
||||||
|
}) {
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const form = e.target
|
||||||
|
const payload = {
|
||||||
|
title: form.title.value.trim(),
|
||||||
|
goal_description: form.goal_description.value.trim(),
|
||||||
|
target_date: form.target_date.value || undefined,
|
||||||
|
item_type: form.item_type.value,
|
||||||
|
sequencing_mode: form.sequencing_mode.value,
|
||||||
|
}
|
||||||
|
if (mode === 'edit') {
|
||||||
|
payload.status = form.status.value
|
||||||
|
}
|
||||||
|
await onSubmit(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLocked = ['reached', 'moved', 'discarded'].includes(initial.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="form workspace-form roadmap-item-form" onSubmit={handleSubmit}>
|
||||||
|
{mode === 'create' && (
|
||||||
|
<label>
|
||||||
|
Typ
|
||||||
|
<select name="item_type" defaultValue={initial.item_type || 'milestone'}>
|
||||||
|
{ITEM_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Titel
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
defaultValue={initial.title || ''}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
placeholder="z. B. MVP nutzbar im Alltag"
|
||||||
|
disabled={statusLocked}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ziel / Definition of Done
|
||||||
|
<textarea
|
||||||
|
name="goal_description"
|
||||||
|
rows={3}
|
||||||
|
defaultValue={initial.goal_description || ''}
|
||||||
|
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
|
||||||
|
disabled={statusLocked}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-row form-row--2">
|
||||||
|
<label>
|
||||||
|
Abfolge
|
||||||
|
<select
|
||||||
|
name="sequencing_mode"
|
||||||
|
defaultValue={initial.sequencing_mode || 'sequential'}
|
||||||
|
disabled={statusLocked}
|
||||||
|
>
|
||||||
|
{SEQUENCING_MODES.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{SEQUENCING_MODE_LABELS[m] || m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Zieltermin
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="target_date"
|
||||||
|
defaultValue={initial.target_date || ''}
|
||||||
|
disabled={statusLocked}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select name="status" defaultValue={initial.status || 'planned'} disabled={statusLocked}>
|
||||||
|
{EDITABLE_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{MILESTONE_STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy || statusLocked}>
|
||||||
|
{busy ? 'Speichern …' : submitLabel}
|
||||||
|
</button>
|
||||||
|
{onCancel && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,25 +2,19 @@ import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { gatePath } from '../utils/routes.js'
|
import { gatePath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
MILESTONE_STATUSES,
|
|
||||||
MILESTONE_STATUS_LABELS,
|
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
SEQUENCING_MODE_LABELS,
|
SEQUENCING_MODE_LABELS,
|
||||||
} from '../constants/status.js'
|
} from '../constants/status.js'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
import { Modal } from './Modal.jsx'
|
||||||
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||||
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
|
|
||||||
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
|
||||||
(s) => !['reached', 'moved', 'discarded'].includes(s)
|
|
||||||
)
|
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
try {
|
try {
|
||||||
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
|
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
|
||||||
'de-DE'
|
'de-DE',
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return value
|
return value
|
||||||
|
|
@ -32,117 +26,37 @@ export function RoadmapPlanSection({
|
||||||
items,
|
items,
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdateStatus,
|
|
||||||
onUpdateItem,
|
|
||||||
onVerifyWithEvidence,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [goalDescription, setGoalDescription] = useState('')
|
|
||||||
const [targetDate, setTargetDate] = useState('')
|
|
||||||
const [itemType, setItemType] = useState('milestone')
|
|
||||||
const [sequencingMode, setSequencingMode] = useState('sequential')
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
|
||||||
const [verifyTitles, setVerifyTitles] = useState({})
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleCreateSubmit(payload) {
|
||||||
e.preventDefault()
|
await onCreate(payload)
|
||||||
if (!title.trim()) return
|
setShowCreate(false)
|
||||||
await onCreate({
|
|
||||||
title: title.trim(),
|
|
||||||
goal_description: goalDescription.trim(),
|
|
||||||
target_date: targetDate || undefined,
|
|
||||||
item_type: itemType,
|
|
||||||
sequencing_mode: sequencingMode,
|
|
||||||
})
|
|
||||||
setTitle('')
|
|
||||||
setGoalDescription('')
|
|
||||||
setTargetDate('')
|
|
||||||
setItemType('milestone')
|
|
||||||
setSequencingMode('sequential')
|
|
||||||
setShowForm(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card roadmap-plan-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Zielzustände (Gates)</h2>
|
<h2>Zielzustände (Gates)</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Überprüfbare Zielpunkte — optional. Operative Planung läuft unter Ausführung.
|
Überprüfbare Zielpunkte — optional. Bearbeitung und Verify auf der Gate-Detailseite,
|
||||||
Erreicht nur über Verify (Kriterien, Evidence, Review).
|
nicht inline in der Liste.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-block-mobile"
|
className="btn btn-primary btn-block-mobile"
|
||||||
onClick={() => setShowForm((v) => !v)}
|
onClick={() => setShowCreate(true)}
|
||||||
>
|
>
|
||||||
{showForm ? 'Abbrechen' : 'Plan-Element'}
|
Plan-Element
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && canManage && (
|
|
||||||
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
|
|
||||||
<label>
|
|
||||||
Typ
|
|
||||||
<select value={itemType} onChange={(e) => setItemType(e.target.value)}>
|
|
||||||
{ITEM_TYPES.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Titel
|
|
||||||
<input
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
maxLength={255}
|
|
||||||
required
|
|
||||||
placeholder="z. B. MVP nutzbar im Alltag"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Ziel / Definition of Done
|
|
||||||
<textarea
|
|
||||||
value={goalDescription}
|
|
||||||
onChange={(e) => setGoalDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Abfolge
|
|
||||||
<select
|
|
||||||
value={sequencingMode}
|
|
||||||
onChange={(e) => setSequencingMode(e.target.value)}
|
|
||||||
>
|
|
||||||
{SEQUENCING_MODES.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{SEQUENCING_MODE_LABELS[m] || m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Zieltermin
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={targetDate}
|
|
||||||
onChange={(e) => setTargetDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
||||||
Anlegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||||
)}
|
)}
|
||||||
|
|
@ -153,7 +67,7 @@ export function RoadmapPlanSection({
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<strong>
|
<strong>
|
||||||
{initiativeId ? (
|
{initiativeId ? (
|
||||||
<Link to={gatePath(item.id)}>
|
<Link to={gatePath(item.id)} className="roadmap-plan-section__title-link">
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -176,84 +90,35 @@ export function RoadmapPlanSection({
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
<StatusBadge kind="milestone" status={item.status} />
|
<StatusBadge kind="milestone" status={item.status} />
|
||||||
{canManage && item.status !== 'reached' && (
|
{initiativeId && (
|
||||||
<>
|
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
||||||
<select
|
Öffnen
|
||||||
className="inline-select"
|
</Link>
|
||||||
value={item.status}
|
|
||||||
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
|
||||||
aria-label="Plan-Status"
|
|
||||||
>
|
|
||||||
{EDITABLE_STATUSES.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{MILESTONE_STATUS_LABELS[s]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="inline-select"
|
|
||||||
value={item.sequencing_mode}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdateItem(item.id, { sequencing_mode: e.target.value })
|
|
||||||
}
|
|
||||||
aria-label="Abfolge"
|
|
||||||
>
|
|
||||||
{SEQUENCING_MODES.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{SEQUENCING_MODE_LABELS[m]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{['planned', 'active', 'at_risk'].includes(item.status) && (
|
|
||||||
<div className="gate-verify-block">
|
|
||||||
<label className="gate-verify-label">
|
|
||||||
Nachweis für Verify
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={verifyTitles[item.id] ?? ''}
|
|
||||||
placeholder={`Nachweis: ${item.title}`}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVerifyTitles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.id]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
onVerifyWithEvidence(
|
|
||||||
item.id,
|
|
||||||
verifyTitles[item.id]?.trim() || `Nachweis: ${item.title}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gate schließen
|
|
||||||
</button>
|
|
||||||
<p className="muted gate-verify-hint">
|
|
||||||
Legt akzeptiertes Evidence am Plan-Element an und setzt Status auf erreicht.
|
|
||||||
Bereits vorhandener Nachweis auf Journey: Plan-Element zuweisen und auf
|
|
||||||
Akzeptiert setzen, dann erneut Verify.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{canManage && item.status !== 'reached' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => onDelete(item.id)}
|
onClick={() => onDelete(item.id)}
|
||||||
|
disabled={busy}
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<Modal open={showCreate} title="Plan-Element anlegen" onClose={() => setShowCreate(false)}>
|
||||||
|
<RoadmapItemForm
|
||||||
|
onSubmit={handleCreateSubmit}
|
||||||
|
onCancel={() => setShowCreate(false)}
|
||||||
|
busy={busy}
|
||||||
|
submitLabel="Anlegen"
|
||||||
|
mode="create"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ export function InitiativePlanPage() {
|
||||||
items={roadmapItems}
|
items={roadmapItems}
|
||||||
canManage={capabilities.has('kairo.milestone.manage')}
|
canManage={capabilities.has('kairo.milestone.manage')}
|
||||||
onCreate={handleCreateRoadmapItem}
|
onCreate={handleCreateRoadmapItem}
|
||||||
onUpdateStatus={handleRoadmapItemStatus}
|
|
||||||
onUpdateItem={handleUpdateRoadmapItem}
|
|
||||||
onVerifyWithEvidence={handleVerifyWithEvidence}
|
|
||||||
onDelete={handleDeleteRoadmapItem}
|
onDelete={handleDeleteRoadmapItem}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
deferRoadmapCriterion,
|
deferRoadmapCriterion,
|
||||||
verifyRoadmapItemReached,
|
verifyRoadmapItemReached,
|
||||||
reopenRoadmapItem,
|
reopenRoadmapItem,
|
||||||
|
updateRoadmapItem,
|
||||||
} from '../../api/roadmap.js'
|
} from '../../api/roadmap.js'
|
||||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
|
@ -23,12 +24,15 @@ import {
|
||||||
CRITERION_STATUS_LABELS,
|
CRITERION_STATUS_LABELS,
|
||||||
MILESTONE_STATUS_LABELS,
|
MILESTONE_STATUS_LABELS,
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
|
SEQUENCING_MODE_LABELS,
|
||||||
} from '../../constants/status.js'
|
} from '../../constants/status.js'
|
||||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
import { scopedPath } from '../../utils/routes.js'
|
import { scopedPath } from '../../utils/routes.js'
|
||||||
import { listGateContributions } from '../../api/journey.js'
|
import { listGateContributions } from '../../api/journey.js'
|
||||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||||
|
import { Modal } from '../../components/Modal.jsx'
|
||||||
|
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
||||||
|
|
||||||
function CriterionDecisionForm({ label, onSubmit, busy }) {
|
function CriterionDecisionForm({ label, onSubmit, busy }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
|
@ -95,6 +99,7 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
const [contributions, setContributions] = useState(null)
|
const [contributions, setContributions] = useState(null)
|
||||||
const [contributionsLoading, setContributionsLoading] = useState(true)
|
const [contributionsLoading, setContributionsLoading] = useState(true)
|
||||||
const [contributionsError, setContributionsError] = useState(null)
|
const [contributionsError, setContributionsError] = useState(null)
|
||||||
|
const [editingMeta, setEditingMeta] = useState(false)
|
||||||
|
|
||||||
const loadContributions = useCallback(async () => {
|
const loadContributions = useCallback(async () => {
|
||||||
setContributionsLoading(true)
|
setContributionsLoading(true)
|
||||||
|
|
@ -171,10 +176,24 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||||
{' · '}
|
{' · '}
|
||||||
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
||||||
|
{' · '}
|
||||||
|
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||||
</p>
|
</p>
|
||||||
{item.goal_description && <p>{item.goal_description}</p>}
|
{item.goal_description && <p>{item.goal_description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="section-header__actions">
|
||||||
<StatusBadge kind="milestone" status={item.status} />
|
<StatusBadge kind="milestone" status={item.status} />
|
||||||
|
{canManage && item.status !== 'reached' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setEditingMeta(true)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Stammdaten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
|
|
@ -365,6 +384,26 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editingMeta}
|
||||||
|
title="Plan-Element bearbeiten"
|
||||||
|
onClose={() => setEditingMeta(false)}
|
||||||
|
>
|
||||||
|
<RoadmapItemForm
|
||||||
|
mode="edit"
|
||||||
|
initial={item}
|
||||||
|
busy={busy}
|
||||||
|
submitLabel="Speichern"
|
||||||
|
onCancel={() => setEditingMeta(false)}
|
||||||
|
onSubmit={async (payload) => {
|
||||||
|
await runAction(async () => {
|
||||||
|
await updateRoadmapItem(itemId, payload)
|
||||||
|
setEditingMeta(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ export function resolvePlanOutlineActiveKey(pathname) {
|
||||||
if (path.startsWith('/projects/')) {
|
if (path.startsWith('/projects/')) {
|
||||||
return 'structure'
|
return 'structure'
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('/actions/')) {
|
||||||
|
return 'work'
|
||||||
|
}
|
||||||
const match = PLAN_OUTLINE_NODES.find(
|
const match = PLAN_OUTLINE_NODES.find(
|
||||||
(node) => path === node.to || path.startsWith(`${node.to}/`),
|
(node) => path === node.to || path.startsWith(`${node.to}/`),
|
||||||
)
|
)
|
||||||
|
|
@ -37,3 +40,12 @@ export function resolvePlanOutlineProjectId(pathname) {
|
||||||
const match = (pathname || '').match(/^\/projects\/([^/?]+)/)
|
const match = (pathname || '').match(/^\/projects\/([^/?]+)/)
|
||||||
return match?.[1] ?? null
|
return match?.[1] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} pathname
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function resolvePlanOutlineActionId(pathname) {
|
||||||
|
const match = (pathname || '').match(/^\/actions\/([^/?]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { PLAN_OUTLINE_NODES, resolvePlanOutlineActiveKey } from './planOutlineNodes.js'
|
import {
|
||||||
|
PLAN_OUTLINE_NODES,
|
||||||
|
resolvePlanOutlineActiveKey,
|
||||||
|
resolvePlanOutlineActionId,
|
||||||
|
resolvePlanOutlineProjectId,
|
||||||
|
} from './planOutlineNodes.js'
|
||||||
|
|
||||||
describe('planOutlineNodes', () => {
|
describe('planOutlineNodes', () => {
|
||||||
it('lists plan sections in product order', () => {
|
it('lists plan sections in product order', () => {
|
||||||
|
|
@ -20,5 +25,11 @@ describe('planOutlineNodes', () => {
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
||||||
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')
|
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')
|
||||||
|
expect(resolvePlanOutlineActiveKey('/actions/act-1')).toBe('work')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves project and action ids from pathname', () => {
|
||||||
|
expect(resolvePlanOutlineProjectId('/projects/abc-123')).toBe('abc-123')
|
||||||
|
expect(resolvePlanOutlineActionId('/actions/act-1')).toBe('act-1')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user