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"
|
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class RoadmapItemUpdateRequest(BaseModel):
|
class RoadmapItemUpdateRequest(BaseModel):
|
||||||
|
|
@ -129,6 +130,19 @@ def list_initiative_roadmap_dependencies(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
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)
|
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
|
||||||
def create_initiative_roadmap_item(
|
def create_initiative_roadmap_item(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
@ -148,6 +162,7 @@ def create_initiative_roadmap_item(
|
||||||
sequencing_mode=body.sequencing_mode,
|
sequencing_mode=body.sequencing_mode,
|
||||||
target_date=body.target_date,
|
target_date=body.target_date,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
|
initial_criterion_title=body.initial_criterion_title,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ def create_roadmap_item(
|
||||||
sequencing_mode: SequencingMode = "sequential",
|
sequencing_mode: SequencingMode = "sequential",
|
||||||
target_date: Optional[date] = None,
|
target_date: Optional[date] = None,
|
||||||
sort_order: int = 0,
|
sort_order: int = 0,
|
||||||
|
initial_criterion_title: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
title = title.strip()
|
title = title.strip()
|
||||||
|
|
@ -280,6 +281,8 @@ def create_roadmap_item(
|
||||||
cur,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
roadmap_item_id=row["id"],
|
roadmap_item_id=row["id"],
|
||||||
|
title=(initial_criterion_title or "Gate allgemein").strip()
|
||||||
|
or "Gate allgemein",
|
||||||
description=goal_description or "",
|
description=goal_description or "",
|
||||||
)
|
)
|
||||||
_sync_milestone_compat_row(
|
_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]:
|
def criteria_progress(*, tenant_id: str, item_id: str) -> dict[str, int]:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -196,3 +196,25 @@ def test_roadmap_dependency_duplicate_rejected(client):
|
||||||
)
|
)
|
||||||
assert dup.status_code == 400
|
assert dup.status_code == 400
|
||||||
assert "existiert bereits" in dup.json()["detail"]
|
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`)
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInitiativeRoadmapCriteriaProgress(initiativeId) {
|
||||||
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/criteria-progress`)
|
||||||
|
}
|
||||||
|
|
||||||
export function createInitiativeRoadmapItem(initiativeId, body) {
|
export function createInitiativeRoadmapItem(initiativeId, body) {
|
||||||
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
|
||||||
method: 'POST',
|
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,
|
item_type: form.item_type.value,
|
||||||
sequencing_mode: form.sequencing_mode.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') {
|
if (mode === 'edit') {
|
||||||
payload.status = form.status.value
|
payload.status = form.status.value
|
||||||
}
|
}
|
||||||
|
|
@ -63,15 +69,31 @@ export function RoadmapItemForm({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Ziel / Definition of Done
|
Zielkontext (optional)
|
||||||
<textarea
|
<textarea
|
||||||
name="goal_description"
|
name="goal_description"
|
||||||
rows={3}
|
rows={3}
|
||||||
defaultValue={initial.goal_description || ''}
|
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}
|
disabled={statusLocked}
|
||||||
/>
|
/>
|
||||||
|
<span className="muted form-hint">
|
||||||
|
Die Checkliste auf der Gate-Detailseite ist führend für Verify.
|
||||||
|
</span>
|
||||||
</label>
|
</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">
|
<div className="form-row form-row--2">
|
||||||
<label>
|
<label>
|
||||||
Abfolge
|
Abfolge
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, 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 { listInitiativeRoadmapCriteriaProgress } from '../api/roadmap.js'
|
||||||
import {
|
import {
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
SEQUENCING_MODE_LABELS,
|
SEQUENCING_MODE_LABELS,
|
||||||
|
|
@ -36,9 +37,28 @@ export function RoadmapPlanSection({
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [dragItemId, setDragItemId] = useState('')
|
const [dragItemId, setDragItemId] = useState('')
|
||||||
const [dropTargetId, setDropTargetId] = useState('')
|
const [dropTargetId, setDropTargetId] = useState('')
|
||||||
|
const [criteriaProgress, setCriteriaProgress] = useState({})
|
||||||
const isDesktop = useMinWidth(1024)
|
const isDesktop = useMinWidth(1024)
|
||||||
const canReorder = canManage && typeof onReorder === 'function'
|
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])
|
const sortedItems = useMemo(() => sortByOrder(items), [items])
|
||||||
|
|
||||||
async function handleCreateSubmit(payload) {
|
async function handleCreateSubmit(payload) {
|
||||||
|
|
@ -115,6 +135,11 @@ export function RoadmapPlanSection({
|
||||||
{sortedItems.map((item, index) => {
|
{sortedItems.map((item, index) => {
|
||||||
const isDragging = dragItemId === item.id
|
const isDragging = dragItemId === item.id
|
||||||
const isDropTarget = dropTargetId === item.id && dragItemId && 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 (
|
return (
|
||||||
<li
|
<li
|
||||||
|
|
@ -159,6 +184,9 @@ export function RoadmapPlanSection({
|
||||||
Zieltermin: {formatDate(item.target_date)}
|
Zieltermin: {formatDate(item.target_date)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{progressLabel && (
|
||||||
|
<p className="muted list-item-sub">Checkliste: {progressLabel}</p>
|
||||||
|
)}
|
||||||
</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} />
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import {
|
import { createInitiativeEvidence } from '../../api/evidence.js'
|
||||||
createInitiativeEvidence,
|
|
||||||
} from '../../api/evidence.js'
|
|
||||||
import {
|
import {
|
||||||
getRoadmapItem,
|
getRoadmapItem,
|
||||||
listRoadmapItemCriteria,
|
listRoadmapItemCriteria,
|
||||||
createRoadmapItemCriterion,
|
|
||||||
deleteRoadmapCriterion,
|
|
||||||
satisfyRoadmapCriterion,
|
|
||||||
waiveRoadmapCriterion,
|
|
||||||
deferRoadmapCriterion,
|
|
||||||
verifyRoadmapItemReached,
|
verifyRoadmapItemReached,
|
||||||
reopenRoadmapItem,
|
reopenRoadmapItem,
|
||||||
updateRoadmapItem,
|
updateRoadmapItem,
|
||||||
|
|
@ -23,9 +16,6 @@ import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
import { StatusBadge } from '../../components/StatusBadge.jsx'
|
||||||
import {
|
import {
|
||||||
CRITERION_KIND_LABELS,
|
|
||||||
CRITERION_KINDS,
|
|
||||||
CRITERION_STATUS_LABELS,
|
|
||||||
MILESTONE_STATUS_LABELS,
|
MILESTONE_STATUS_LABELS,
|
||||||
ROADMAP_ITEM_TYPE_LABELS,
|
ROADMAP_ITEM_TYPE_LABELS,
|
||||||
SEQUENCING_MODE_LABELS,
|
SEQUENCING_MODE_LABELS,
|
||||||
|
|
@ -36,52 +26,10 @@ 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 { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
|
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
|
||||||
|
import { GateCriteriaSection } from '../../components/GateCriteriaSection.jsx'
|
||||||
import { Modal } from '../../components/Modal.jsx'
|
import { Modal } from '../../components/Modal.jsx'
|
||||||
import { RoadmapItemForm } from '../../components/RoadmapItemForm.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 }) {
|
export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
|
||||||
const ops = useInitiativeOperations()
|
const ops = useInitiativeOperations()
|
||||||
|
|
@ -97,9 +45,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [newTitle, setNewTitle] = useState('')
|
|
||||||
const [newKind, setNewKind] = useState('manual')
|
|
||||||
const [expandedDecision, setExpandedDecision] = useState(null)
|
|
||||||
const [reopenReason, setReopenReason] = useState('')
|
const [reopenReason, setReopenReason] = useState('')
|
||||||
const [contributions, setContributions] = useState(null)
|
const [contributions, setContributions] = useState(null)
|
||||||
const [contributionsLoading, setContributionsLoading] = useState(true)
|
const [contributionsLoading, setContributionsLoading] = useState(true)
|
||||||
|
|
@ -151,6 +96,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
}
|
}
|
||||||
}, [itemId, initiativeId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
@ -159,6 +114,19 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
loadContributions()
|
loadContributions()
|
||||||
}, [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) {
|
async function runAction(fn) {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -176,9 +144,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
if (error && !item) return <ErrorState message={error} onRetry={load} />
|
if (error && !item) return <ErrorState message={error} onRetry={load} />
|
||||||
if (!item) return <ErrorState message="Plan-Element nicht gefunden." 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 (
|
return (
|
||||||
<section className="card roadmap-item-detail">
|
<section className="card roadmap-item-detail">
|
||||||
<p className="breadcrumb muted">
|
<p className="breadcrumb muted">
|
||||||
|
|
@ -199,7 +164,9 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
{' · '}
|
{' · '}
|
||||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||||
</p>
|
</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>
|
||||||
<div className="section-header__actions">
|
<div className="section-header__actions">
|
||||||
<StatusBadge kind="milestone" status={item.status} />
|
<StatusBadge kind="milestone" status={item.status} />
|
||||||
|
|
@ -216,10 +183,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="muted">
|
|
||||||
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<GateContributionsSection
|
<GateContributionsSection
|
||||||
contributions={contributions}
|
contributions={contributions}
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
|
|
@ -237,136 +200,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
|
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ul className="item-list criterion-list">
|
<GateCriteriaSection
|
||||||
{criteria.map((crit) => (
|
itemId={itemId}
|
||||||
<li key={crit.id} className="list-item card-list-item">
|
itemStatus={item.status}
|
||||||
<div className="list-item-main">
|
criteria={criteria}
|
||||||
<strong>{crit.title}</strong>
|
progress={progress}
|
||||||
<p className="muted list-item-sub">
|
canManage={canManage}
|
||||||
{CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind}
|
busy={busy}
|
||||||
</p>
|
onChanged={reloadCriteria}
|
||||||
{crit.description && <p className="list-item-desc">{crit.description}</p>}
|
onAction={runCriteriaAction}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="gate-detail-actions">
|
<div className="gate-detail-actions">
|
||||||
{canManage && ['planned', 'active', 'at_risk'].includes(item.status) && (
|
{canManage && ['planned', 'active', 'at_risk'].includes(item.status) && (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user