AP1.4c: Gate-Checkliste statt Freitext-DoD.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m13s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m13s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Kriterien-Fortschritt in der Gate-Liste, Modal-UX fuer Checklisten und optionales Erst-Kriterium beim Anlegen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dbe6f4f684
commit
bd64340ca1
|
|
@ -26,6 +26,7 @@ class RoadmapItemCreateRequest(BaseModel):
|
|||
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
||||
target_date: Optional[date] = None
|
||||
sort_order: int = 0
|
||||
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class RoadmapItemUpdateRequest(BaseModel):
|
||||
|
|
@ -129,6 +130,19 @@ def list_initiative_roadmap_dependencies(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/criteria-progress")
|
||||
def list_initiative_roadmap_criteria_progress(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
return criteria_service.criteria_progress_for_initiative(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
||||
def create_initiative_roadmap_item(
|
||||
initiative_id: str,
|
||||
|
|
@ -148,6 +162,7 @@ def create_initiative_roadmap_item(
|
|||
sequencing_mode=body.sequencing_mode,
|
||||
target_date=body.target_date,
|
||||
sort_order=body.sort_order,
|
||||
initial_criterion_title=body.initial_criterion_title,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ def create_roadmap_item(
|
|||
sequencing_mode: SequencingMode = "sequential",
|
||||
target_date: Optional[date] = None,
|
||||
sort_order: int = 0,
|
||||
initial_criterion_title: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
|
|
@ -280,6 +281,8 @@ def create_roadmap_item(
|
|||
cur,
|
||||
tenant_id=tenant_id,
|
||||
roadmap_item_id=row["id"],
|
||||
title=(initial_criterion_title or "Gate allgemein").strip()
|
||||
or "Gate allgemein",
|
||||
description=goal_description or "",
|
||||
)
|
||||
_sync_milestone_compat_row(
|
||||
|
|
|
|||
|
|
@ -562,6 +562,46 @@ def defer_criterion(
|
|||
)
|
||||
|
||||
|
||||
def criteria_progress_for_initiative(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> dict[str, dict[str, int]]:
|
||||
from services.initiatives import get_initiative
|
||||
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ri.id::text,
|
||||
COUNT(c.id) AS total,
|
||||
COUNT(c.id) FILTER (
|
||||
WHERE c.status IN ('satisfied', 'waived', 'deferred')
|
||||
) AS closed
|
||||
FROM roadmap_items ri
|
||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||
LEFT JOIN roadmap_item_criteria c
|
||||
ON c.roadmap_item_id = ri.id AND c.tenant_id = ri.tenant_id
|
||||
WHERE ri.tenant_id = %s AND r.initiative_id = %s
|
||||
GROUP BY ri.id
|
||||
""",
|
||||
(tenant_id, initiative_id),
|
||||
)
|
||||
return {
|
||||
row[0]: {
|
||||
"total": row[1],
|
||||
"closed": row[2],
|
||||
"open": row[1] - row[2],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def criteria_progress(*, tenant_id: str, item_id: str) -> dict[str, int]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -196,3 +196,25 @@ def test_roadmap_dependency_duplicate_rejected(client):
|
|||
)
|
||||
assert dup.status_code == 400
|
||||
assert "existiert bereits" in dup.json()["detail"]
|
||||
|
||||
|
||||
def test_initiative_roadmap_criteria_progress(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
item = _create_roadmap_item(
|
||||
client,
|
||||
token,
|
||||
initiative_id,
|
||||
title="Gate mit Kriterium",
|
||||
initial_criterion_title="Erstes Kriterium",
|
||||
).json()
|
||||
|
||||
progress = client.get(
|
||||
f"/api/initiatives/{initiative_id}/roadmap/criteria-progress",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert progress.status_code == 200
|
||||
body = progress.json()
|
||||
assert body[item["id"]]["total"] >= 1
|
||||
assert body[item["id"]]["closed"] == 0
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export function listInitiativeRoadmapDependencies(initiativeId) {
|
|||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
|
||||
}
|
||||
|
||||
export function listInitiativeRoadmapCriteriaProgress(initiativeId) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/criteria-progress`)
|
||||
}
|
||||
|
||||
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
253
frontend/src/components/GateCriteriaSection.jsx
Normal file
253
frontend/src/components/GateCriteriaSection.jsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { useState } from 'react'
|
||||
import {
|
||||
CRITERION_KIND_LABELS,
|
||||
CRITERION_STATUS_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { Modal } from './Modal.jsx'
|
||||
import { GateCriterionForm } from './GateCriterionForm.jsx'
|
||||
import {
|
||||
createRoadmapItemCriterion,
|
||||
deleteRoadmapCriterion,
|
||||
deferRoadmapCriterion,
|
||||
satisfyRoadmapCriterion,
|
||||
updateRoadmapCriterion,
|
||||
waiveRoadmapCriterion,
|
||||
} from '../api/roadmap.js'
|
||||
|
||||
function CriterionDecisionForm({ label, onSubmit, busy, onCancel }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
return (
|
||||
<form
|
||||
className="inline-form-block criterion-decision-form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onSubmit({
|
||||
decision_title: title.trim(),
|
||||
decision_description: description.trim(),
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{label} — Begründung (Decision)
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Kurzbegründung"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-secondary" disabled={busy}>
|
||||
Speichern
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function GateCriteriaSection({
|
||||
itemId,
|
||||
itemStatus = 'planned',
|
||||
criteria = [],
|
||||
progress = null,
|
||||
canManage = false,
|
||||
busy = false,
|
||||
onChanged,
|
||||
onAction,
|
||||
}) {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingCriterion, setEditingCriterion] = useState(null)
|
||||
const [decisionModal, setDecisionModal] = useState(null)
|
||||
|
||||
const closedCount = progress?.closed ?? criteria.filter((c) =>
|
||||
['satisfied', 'waived', 'deferred'].includes(c.status),
|
||||
).length
|
||||
const totalCount = progress?.total ?? criteria.length
|
||||
const itemReached = itemStatus === 'reached'
|
||||
|
||||
async function runAction(fn) {
|
||||
if (onAction) {
|
||||
await onAction(fn)
|
||||
return
|
||||
}
|
||||
await fn()
|
||||
await onChanged?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="gate-criteria-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h3>Checkliste</h3>
|
||||
<p className="section-lead muted">
|
||||
{closedCount}/{totalCount} Kriterien abgeschlossen — prüfbare Akzeptanz statt Freitext-DoD.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setShowCreate(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
Kriterium
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{criteria.length === 0 && (
|
||||
<p className="muted gate-criteria-section__empty">
|
||||
Noch keine Kriterien — lege prüfbare Akzeptanzpunkte an.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="item-list criterion-list">
|
||||
{criteria.map((crit) => (
|
||||
<li key={crit.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{crit.title}</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind}
|
||||
</p>
|
||||
{crit.description && <p className="list-item-desc">{crit.description}</p>}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<span className="status-pill">
|
||||
{CRITERION_STATUS_LABELS[crit.status] || crit.status}
|
||||
</span>
|
||||
{canManage && crit.status === 'open' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => runAction(() => satisfyRoadmapCriterion(crit.id))}
|
||||
>
|
||||
Erfüllt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setDecisionModal({ id: crit.id, action: 'waive' })}
|
||||
>
|
||||
Auslassen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setDecisionModal({ id: crit.id, action: 'defer' })}
|
||||
>
|
||||
Verschieben
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setEditingCriterion(crit)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{canManage && criteria.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => runAction(() => deleteRoadmapCriterion(crit.id))}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Modal open={showCreate} title="Kriterium anlegen" onClose={() => setShowCreate(false)}>
|
||||
<GateCriterionForm
|
||||
mode="create"
|
||||
submitLabel="Anlegen"
|
||||
busy={busy}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onSubmit={async (payload) => {
|
||||
await runAction(async () => {
|
||||
await createRoadmapItemCriterion(itemId, payload)
|
||||
setShowCreate(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editingCriterion)}
|
||||
title="Kriterium bearbeiten"
|
||||
onClose={() => setEditingCriterion(null)}
|
||||
>
|
||||
{editingCriterion && (
|
||||
<GateCriterionForm
|
||||
mode="edit"
|
||||
initial={editingCriterion}
|
||||
itemReached={itemReached}
|
||||
submitLabel="Speichern"
|
||||
busy={busy}
|
||||
onCancel={() => setEditingCriterion(null)}
|
||||
onSubmit={async (payload) => {
|
||||
await runAction(async () => {
|
||||
await updateRoadmapCriterion(editingCriterion.id, payload)
|
||||
setEditingCriterion(null)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(decisionModal)}
|
||||
title={decisionModal?.action === 'defer' ? 'Kriterium verschieben' : 'Kriterium auslassen'}
|
||||
onClose={() => setDecisionModal(null)}
|
||||
>
|
||||
{decisionModal && (
|
||||
<CriterionDecisionForm
|
||||
label={decisionModal.action === 'defer' ? 'Verschieben' : 'Auslassen'}
|
||||
busy={busy}
|
||||
onCancel={() => setDecisionModal(null)}
|
||||
onSubmit={async (body) => {
|
||||
await runAction(async () => {
|
||||
const fn =
|
||||
decisionModal.action === 'defer'
|
||||
? deferRoadmapCriterion
|
||||
: waiveRoadmapCriterion
|
||||
await fn(decisionModal.id, body)
|
||||
setDecisionModal(null)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
81
frontend/src/components/GateCriterionForm.jsx
Normal file
81
frontend/src/components/GateCriterionForm.jsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { CRITERION_KIND_LABELS, CRITERION_KINDS } from '../constants/status.js'
|
||||
|
||||
export function GateCriterionForm({
|
||||
initial = {},
|
||||
onSubmit,
|
||||
onCancel,
|
||||
busy = false,
|
||||
submitLabel = 'Speichern',
|
||||
mode = 'create',
|
||||
itemReached = false,
|
||||
}) {
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
const form = e.target
|
||||
const payload = {
|
||||
title: form.title.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
criterion_kind: form.criterion_kind.value,
|
||||
}
|
||||
if (mode === 'edit' && itemReached) {
|
||||
payload.change_reason = form.change_reason.value.trim()
|
||||
}
|
||||
await onSubmit(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form workspace-form gate-criterion-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={initial.title || ''}
|
||||
maxLength={500}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="z. B. Akzeptanzkriterium dokumentiert"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung (optional)
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={initial.description || ''}
|
||||
placeholder="Prüfhinweis oder Kontext"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Art
|
||||
<select name="criterion_kind" defaultValue={initial.criterion_kind || 'manual'}>
|
||||
{CRITERION_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{CRITERION_KIND_LABELS[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{mode === 'edit' && itemReached && (
|
||||
<label>
|
||||
Begründung (Gate bereits erreicht)
|
||||
<input
|
||||
name="change_reason"
|
||||
maxLength={500}
|
||||
required
|
||||
placeholder="Warum wird das Kriterium geändert?"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Speichern …' : submitLabel}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,6 +29,12 @@ export function RoadmapItemForm({
|
|||
item_type: form.item_type.value,
|
||||
sequencing_mode: form.sequencing_mode.value,
|
||||
}
|
||||
if (mode === 'create') {
|
||||
const initialCriterion = form.initial_criterion_title?.value?.trim()
|
||||
if (initialCriterion) {
|
||||
payload.initial_criterion_title = initialCriterion
|
||||
}
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
payload.status = form.status.value
|
||||
}
|
||||
|
|
@ -63,15 +69,31 @@ export function RoadmapItemForm({
|
|||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ziel / Definition of Done
|
||||
Zielkontext (optional)
|
||||
<textarea
|
||||
name="goal_description"
|
||||
rows={3}
|
||||
defaultValue={initial.goal_description || ''}
|
||||
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
|
||||
placeholder="Kurzer Kontext — prüfbare Akzeptanz legst du als Kriterien an."
|
||||
disabled={statusLocked}
|
||||
/>
|
||||
<span className="muted form-hint">
|
||||
Die Checkliste auf der Gate-Detailseite ist führend für Verify.
|
||||
</span>
|
||||
</label>
|
||||
{mode === 'create' && (
|
||||
<label>
|
||||
Erstes Kriterium (optional)
|
||||
<input
|
||||
name="initial_criterion_title"
|
||||
maxLength={500}
|
||||
placeholder="z. B. Demo mit Stakeholdern durchgeführt"
|
||||
/>
|
||||
<span className="muted form-hint">
|
||||
Ohne Angabe wird „Gate allgemein“ als Standard-Kriterium angelegt.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="form-row form-row--2">
|
||||
<label>
|
||||
Abfolge
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { gatePath } from '../utils/routes.js'
|
||||
import { listInitiativeRoadmapCriteriaProgress } from '../api/roadmap.js'
|
||||
import {
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
|
|
@ -36,9 +37,28 @@ export function RoadmapPlanSection({
|
|||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [dragItemId, setDragItemId] = useState('')
|
||||
const [dropTargetId, setDropTargetId] = useState('')
|
||||
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||
const isDesktop = useMinWidth(1024)
|
||||
const canReorder = canManage && typeof onReorder === 'function'
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setCriteriaProgress({})
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
listInitiativeRoadmapCriteriaProgress(initiativeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setCriteriaProgress(data || {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCriteriaProgress({})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId, items])
|
||||
|
||||
const sortedItems = useMemo(() => sortByOrder(items), [items])
|
||||
|
||||
async function handleCreateSubmit(payload) {
|
||||
|
|
@ -115,6 +135,11 @@ export function RoadmapPlanSection({
|
|||
{sortedItems.map((item, index) => {
|
||||
const isDragging = dragItemId === item.id
|
||||
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
||||
const progress = criteriaProgress[item.id]
|
||||
const progressLabel =
|
||||
progress && progress.total > 0
|
||||
? `${progress.closed}/${progress.total} Kriterien`
|
||||
: null
|
||||
|
||||
return (
|
||||
<li
|
||||
|
|
@ -159,6 +184,9 @@ export function RoadmapPlanSection({
|
|||
Zieltermin: {formatDate(item.target_date)}
|
||||
</p>
|
||||
)}
|
||||
{progressLabel && (
|
||||
<p className="muted list-item-sub">Checkliste: {progressLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
createInitiativeEvidence,
|
||||
} from '../../api/evidence.js'
|
||||
import { createInitiativeEvidence } from '../../api/evidence.js'
|
||||
import {
|
||||
getRoadmapItem,
|
||||
listRoadmapItemCriteria,
|
||||
createRoadmapItemCriterion,
|
||||
deleteRoadmapCriterion,
|
||||
satisfyRoadmapCriterion,
|
||||
waiveRoadmapCriterion,
|
||||
deferRoadmapCriterion,
|
||||
verifyRoadmapItemReached,
|
||||
reopenRoadmapItem,
|
||||
updateRoadmapItem,
|
||||
|
|
@ -23,9 +16,6 @@ import { ErrorState } from '../../components/ErrorState.jsx'
|
|||
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||
import {
|
||||
CRITERION_KIND_LABELS,
|
||||
CRITERION_KINDS,
|
||||
CRITERION_STATUS_LABELS,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
|
|
@ -36,52 +26,10 @@ import { scopedPath } from '../../utils/routes.js'
|
|||
import { listGateContributions } from '../../api/journey.js'
|
||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
|
||||
import { GateCriteriaSection } from '../../components/GateCriteriaSection.jsx'
|
||||
import { Modal } from '../../components/Modal.jsx'
|
||||
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
||||
|
||||
function CriterionDecisionForm({ label, onSubmit, busy }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
return (
|
||||
<form
|
||||
className="inline-form-block criterion-decision-form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onSubmit({
|
||||
decision_title: title.trim(),
|
||||
decision_description: description.trim(),
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{label} — Begründung (Decision)
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Kurzbegründung"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-secondary" disabled={busy}>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
||||
const ops = useInitiativeOperations()
|
||||
|
|
@ -97,9 +45,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newKind, setNewKind] = useState('manual')
|
||||
const [expandedDecision, setExpandedDecision] = useState(null)
|
||||
const [reopenReason, setReopenReason] = useState('')
|
||||
const [contributions, setContributions] = useState(null)
|
||||
const [contributionsLoading, setContributionsLoading] = useState(true)
|
||||
|
|
@ -151,6 +96,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
}
|
||||
}, [itemId, initiativeId])
|
||||
|
||||
const reloadCriteria = useCallback(async () => {
|
||||
try {
|
||||
const criteriaData = await listRoadmapItemCriteria(itemId)
|
||||
setCriteria(criteriaData.items || [])
|
||||
setProgress(criteriaData.progress || null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
|
@ -159,6 +114,19 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
loadContributions()
|
||||
}, [loadContributions])
|
||||
|
||||
async function runCriteriaAction(fn) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await fn()
|
||||
await reloadCriteria()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(fn) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
|
@ -176,9 +144,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
if (error && !item) return <ErrorState message={error} onRetry={load} />
|
||||
if (!item) return <ErrorState message="Plan-Element nicht gefunden." onRetry={load} />
|
||||
|
||||
const closedCount = progress?.closed ?? 0
|
||||
const totalCount = progress?.total ?? criteria.length
|
||||
|
||||
return (
|
||||
<section className="card roadmap-item-detail">
|
||||
<p className="breadcrumb muted">
|
||||
|
|
@ -199,7 +164,9 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
{item.goal_description && <p>{item.goal_description}</p>}
|
||||
{item.goal_description && (
|
||||
<p className="roadmap-item-detail__goal-context">{item.goal_description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="section-header__actions">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
|
|
@ -216,10 +183,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p className="muted">
|
||||
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
|
||||
</p>
|
||||
|
||||
<GateContributionsSection
|
||||
contributions={contributions}
|
||||
initiativeId={initiativeId}
|
||||
|
|
@ -237,136 +200,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
|
||||
/>
|
||||
|
||||
<ul className="item-list criterion-list">
|
||||
{criteria.map((crit) => (
|
||||
<li key={crit.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{crit.title}</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind}
|
||||
</p>
|
||||
{crit.description && <p className="list-item-desc">{crit.description}</p>}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<span className="status-pill">
|
||||
{CRITERION_STATUS_LABELS[crit.status] || crit.status}
|
||||
</span>
|
||||
{canManage && crit.status === 'open' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() => runAction(() => satisfyRoadmapCriterion(crit.id))}
|
||||
>
|
||||
Erfüllt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setExpandedDecision(
|
||||
expandedDecision === `${crit.id}:waive` ? null : `${crit.id}:waive`
|
||||
)
|
||||
}
|
||||
>
|
||||
Auslassen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setExpandedDecision(
|
||||
expandedDecision === `${crit.id}:defer` ? null : `${crit.id}:defer`
|
||||
)
|
||||
}
|
||||
>
|
||||
Verschieben
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canManage && criteria.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => runAction(() => deleteRoadmapCriterion(crit.id))}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{expandedDecision === `${crit.id}:waive` && (
|
||||
<CriterionDecisionForm
|
||||
label="Auslassen"
|
||||
busy={busy}
|
||||
onSubmit={(body) =>
|
||||
runAction(async () => {
|
||||
await waiveRoadmapCriterion(crit.id, body)
|
||||
setExpandedDecision(null)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{expandedDecision === `${crit.id}:defer` && (
|
||||
<CriterionDecisionForm
|
||||
label="Verschieben"
|
||||
busy={busy}
|
||||
onSubmit={(body) =>
|
||||
runAction(async () => {
|
||||
await deferRoadmapCriterion(crit.id, body)
|
||||
setExpandedDecision(null)
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="inline-form-block"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!newTitle.trim()) return
|
||||
runAction(async () => {
|
||||
await createRoadmapItemCriterion(itemId, {
|
||||
title: newTitle.trim(),
|
||||
criterion_kind: newKind,
|
||||
})
|
||||
setNewTitle('')
|
||||
setNewKind('manual')
|
||||
})
|
||||
}}
|
||||
>
|
||||
<h3>Kriterium hinzufügen</h3>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
maxLength={500}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Art
|
||||
<select value={newKind} onChange={(e) => setNewKind(e.target.value)}>
|
||||
{CRITERION_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{CRITERION_KIND_LABELS[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<GateCriteriaSection
|
||||
itemId={itemId}
|
||||
itemStatus={item.status}
|
||||
criteria={criteria}
|
||||
progress={progress}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onChanged={reloadCriteria}
|
||||
onAction={runCriteriaAction}
|
||||
/>
|
||||
|
||||
<div className="gate-detail-actions">
|
||||
{canManage && ['planned', 'active', 'at_risk'].includes(item.status) && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user