Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m31s
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 1s
Test Suite / compose-smoke (push) Has been skipped
Verify prüft akzeptiertes Evidence am RoadmapItem. Plan-UI schließt Gates mit Nachweis; Journey erlaubt Plan-Zuordnung. Migration 011 roadmap_item_id auf evidence/reviews. Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
8.3 KiB
JavaScript
249 lines
8.3 KiB
JavaScript
import { useState } from 'react'
|
|
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)
|
|
)
|
|
|
|
function formatDate(value) {
|
|
if (!value) return null
|
|
try {
|
|
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
|
|
'de-DE'
|
|
)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
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({})
|
|
|
|
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)
|
|
}
|
|
|
|
return (
|
|
<section className="card">
|
|
<div className="section-header">
|
|
<div>
|
|
<h2>Plan</h2>
|
|
<p className="section-lead muted">
|
|
Roadmap-Elemente methodenneutral — Gates, Reifegrade oder Review-Punkte.
|
|
Erreicht nur über Verify (Evidence, Review oder Decision).
|
|
</p>
|
|
</div>
|
|
{canManage && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-block-mobile"
|
|
onClick={() => setShowForm((v) => !v)}
|
|
>
|
|
{showForm ? 'Abbrechen' : '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." />
|
|
)}
|
|
|
|
<ul className="item-list milestone-list">
|
|
{items.map((item) => (
|
|
<li key={item.id} className="list-item card-list-item milestone-list-item">
|
|
<div className="list-item-main">
|
|
<strong>{item.title}</strong>
|
|
<p className="muted list-item-sub">
|
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
|
{' · '}
|
|
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
|
</p>
|
|
{item.goal_description && (
|
|
<p className="list-item-desc">{item.goal_description}</p>
|
|
)}
|
|
{item.target_date && (
|
|
<p className="muted list-item-sub">
|
|
Zieltermin: {formatDate(item.target_date)}
|
|
</p>
|
|
)}
|
|
</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>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => onDelete(item.id)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|