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 { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||
import { buildProjectsByParent } from '../utils/projectTree.js'
|
||||
import { projectPath } from '../utils/routes.js'
|
||||
import { projectPath, actionPath } from '../utils/routes.js'
|
||||
import {
|
||||
PLAN_OUTLINE_NODES,
|
||||
resolvePlanOutlineActiveKey,
|
||||
resolvePlanOutlineProjectId,
|
||||
resolvePlanOutlineActionId,
|
||||
} from '../plan/planOutlineNodes.js'
|
||||
|
||||
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
||||
|
|
@ -52,16 +53,25 @@ export function PlanOutlineNav() {
|
|||
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
||||
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
||||
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
||||
const activeActionId = resolvePlanOutlineActionId(location.pathname)
|
||||
const [counts, setCounts] = useState({ inbox: null, work: null })
|
||||
const [projects, setProjects] = useState([])
|
||||
const [openActions, setOpenActions] = useState([])
|
||||
const [structureOpen, setStructureOpen] = useState(
|
||||
activeKey === 'structure' || Boolean(activeProjectId),
|
||||
)
|
||||
const [workOpen, setWorkOpen] = useState(
|
||||
activeKey === 'work' || Boolean(activeActionId),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
|
||||
}, [activeKey, activeProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
setWorkOpen(activeKey === 'work' || Boolean(activeActionId))
|
||||
}, [activeKey, activeActionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setProjects([])
|
||||
|
|
@ -91,11 +101,13 @@ export function PlanOutlineNav() {
|
|||
listInitiativeActions(initiativeId).catch(() => []),
|
||||
]).then(([backlog, actions]) => {
|
||||
if (cancelled) return
|
||||
const open = actions.filter(
|
||||
(action) => action.status !== 'done' && action.status !== 'discarded',
|
||||
)
|
||||
setOpenActions(open)
|
||||
setCounts({
|
||||
inbox: backlog.filter((item) => item.status !== 'converted').length,
|
||||
work: actions.filter(
|
||||
(action) => action.status !== 'done' && action.status !== 'discarded',
|
||||
).length,
|
||||
work: open.length,
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
|
|
@ -136,7 +148,9 @@ export function PlanOutlineNav() {
|
|||
const needsInitiative = node.requiresInitiative && !initiativeId
|
||||
const isDisabled = node.disabled || needsInitiative
|
||||
const isStructure = node.key === 'structure'
|
||||
const isWork = node.key === 'work'
|
||||
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
||||
const hasActionList = isWork && openActions.length > 0 && initiativeId
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
|
|
@ -169,12 +183,25 @@ export function PlanOutlineNav() {
|
|||
{structureOpen ? '▾' : '▸'}
|
||||
</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
|
||||
to={hrefWithScope(node.to)}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
'analysis-split__nav-link' +
|
||||
(isActive || (isStructure && activeProjectId)
|
||||
(isActive ||
|
||||
(isStructure && activeProjectId) ||
|
||||
(isWork && activeActionId)
|
||||
? ' analysis-split__nav-link--active'
|
||||
: '')
|
||||
}
|
||||
|
|
@ -189,6 +216,25 @@ export function PlanOutlineNav() {
|
|||
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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
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 { gatePath } from '../utils/routes.js'
|
||||
import {
|
||||
MILESTONE_STATUSES,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
||||
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)
|
||||
)
|
||||
import { Modal } from './Modal.jsx'
|
||||
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return null
|
||||
try {
|
||||
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
|
||||
'de-DE'
|
||||
'de-DE',
|
||||
)
|
||||
} catch {
|
||||
return value
|
||||
|
|
@ -32,117 +26,37 @@ export function RoadmapPlanSection({
|
|||
items,
|
||||
canManage,
|
||||
onCreate,
|
||||
onUpdateStatus,
|
||||
onUpdateItem,
|
||||
onVerifyWithEvidence,
|
||||
onDelete,
|
||||
busy,
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
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({})
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
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)
|
||||
async function handleCreateSubmit(payload) {
|
||||
await onCreate(payload)
|
||||
setShowCreate(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<section className="card roadmap-plan-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Zielzustände (Gates)</h2>
|
||||
<p className="section-lead muted">
|
||||
Überprüfbare Zielpunkte — optional. Operative Planung läuft unter Ausführung.
|
||||
Erreicht nur über Verify (Kriterien, Evidence, Review).
|
||||
Überprüfbare Zielpunkte — optional. Bearbeitung und Verify auf der Gate-Detailseite,
|
||||
nicht inline in der Liste.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : 'Plan-Element'}
|
||||
Plan-Element
|
||||
</button>
|
||||
)}
|
||||
</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 && (
|
||||
<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">
|
||||
<strong>
|
||||
{initiativeId ? (
|
||||
<Link to={gatePath(item.id)}>
|
||||
<Link to={gatePath(item.id)} className="roadmap-plan-section__title-link">
|
||||
{item.title}
|
||||
</Link>
|
||||
) : (
|
||||
|
|
@ -176,84 +90,35 @@ export function RoadmapPlanSection({
|
|||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
{canManage && item.status !== 'reached' && (
|
||||
<>
|
||||
<select
|
||||
className="inline-select"
|
||||
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>
|
||||
{initiativeId && (
|
||||
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
|
||||
Öffnen
|
||||
</Link>
|
||||
)}
|
||||
{canManage && item.status !== 'reached' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onDelete(item.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,6 @@ export function InitiativePlanPage() {
|
|||
items={roadmapItems}
|
||||
canManage={capabilities.has('kairo.milestone.manage')}
|
||||
onCreate={handleCreateRoadmapItem}
|
||||
onUpdateStatus={handleRoadmapItemStatus}
|
||||
onUpdateItem={handleUpdateRoadmapItem}
|
||||
onVerifyWithEvidence={handleVerifyWithEvidence}
|
||||
onDelete={handleDeleteRoadmapItem}
|
||||
busy={formBusy}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
deferRoadmapCriterion,
|
||||
verifyRoadmapItemReached,
|
||||
reopenRoadmapItem,
|
||||
updateRoadmapItem,
|
||||
} from '../../api/roadmap.js'
|
||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||
|
|
@ -23,12 +24,15 @@ import {
|
|||
CRITERION_STATUS_LABELS,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
} from '../../constants/status.js'
|
||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
import { scopedPath } from '../../utils/routes.js'
|
||||
import { listGateContributions } from '../../api/journey.js'
|
||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||
import { Modal } from '../../components/Modal.jsx'
|
||||
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
||||
|
||||
function CriterionDecisionForm({ label, onSubmit, busy }) {
|
||||
const [title, setTitle] = useState('')
|
||||
|
|
@ -95,6 +99,7 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
const [contributions, setContributions] = useState(null)
|
||||
const [contributionsLoading, setContributionsLoading] = useState(true)
|
||||
const [contributionsError, setContributionsError] = useState(null)
|
||||
const [editingMeta, setEditingMeta] = useState(false)
|
||||
|
||||
const loadContributions = useCallback(async () => {
|
||||
setContributionsLoading(true)
|
||||
|
|
@ -171,10 +176,24 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
||||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
{item.goal_description && <p>{item.goal_description}</p>}
|
||||
</div>
|
||||
<div className="section-header__actions">
|
||||
<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>
|
||||
|
||||
<p className="muted">
|
||||
|
|
@ -365,6 +384,26 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export function resolvePlanOutlineActiveKey(pathname) {
|
|||
if (path.startsWith('/projects/')) {
|
||||
return 'structure'
|
||||
}
|
||||
if (path.startsWith('/actions/')) {
|
||||
return 'work'
|
||||
}
|
||||
const match = PLAN_OUTLINE_NODES.find(
|
||||
(node) => path === node.to || path.startsWith(`${node.to}/`),
|
||||
)
|
||||
|
|
@ -37,3 +40,12 @@ export function resolvePlanOutlineProjectId(pathname) {
|
|||
const match = (pathname || '').match(/^\/projects\/([^/?]+)/)
|
||||
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 { PLAN_OUTLINE_NODES, resolvePlanOutlineActiveKey } from './planOutlineNodes.js'
|
||||
import {
|
||||
PLAN_OUTLINE_NODES,
|
||||
resolvePlanOutlineActiveKey,
|
||||
resolvePlanOutlineActionId,
|
||||
resolvePlanOutlineProjectId,
|
||||
} from './planOutlineNodes.js'
|
||||
|
||||
describe('planOutlineNodes', () => {
|
||||
it('lists plan sections in product order', () => {
|
||||
|
|
@ -20,5 +25,11 @@ describe('planOutlineNodes', () => {
|
|||
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
||||
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
||||
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