fix: Gate-Verify — Evidence an Plan-Element verknüpfen
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
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>
This commit is contained in:
parent
09433b5d28
commit
fdda8a9fae
29
backend/migrations/011_roadmap_item_links.sql
Normal file
29
backend/migrations/011_roadmap_item_links.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- AP1.4b: Plan-Ist-Verknüpfung für Evidence und Reviews
|
||||||
|
|
||||||
|
ALTER TABLE evidence
|
||||||
|
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE reviews
|
||||||
|
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_evidence_roadmap_item ON evidence(tenant_id, roadmap_item_id)
|
||||||
|
WHERE roadmap_item_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_reviews_roadmap_item ON reviews(tenant_id, roadmap_item_id)
|
||||||
|
WHERE roadmap_item_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Bestehende milestone_id-Verknüpfungen spiegeln (RoadmapItem behält oft dieselbe UUID)
|
||||||
|
UPDATE evidence e
|
||||||
|
SET roadmap_item_id = e.milestone_id
|
||||||
|
WHERE e.milestone_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM roadmap_items ri
|
||||||
|
WHERE ri.id = e.milestone_id AND ri.tenant_id = e.tenant_id
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE reviews r
|
||||||
|
SET roadmap_item_id = r.milestone_id
|
||||||
|
WHERE r.milestone_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM roadmap_items ri
|
||||||
|
WHERE ri.id = r.milestone_id AND ri.tenant_id = r.tenant_id
|
||||||
|
);
|
||||||
|
|
@ -19,8 +19,10 @@ class EvidenceUpdateRequest(BaseModel):
|
||||||
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
|
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
|
||||||
action_id: Optional[str] = None
|
action_id: Optional[str] = None
|
||||||
milestone_id: Optional[str] = None
|
milestone_id: Optional[str] = None
|
||||||
|
roadmap_item_id: Optional[str] = None
|
||||||
clear_action_id: bool = False
|
clear_action_id: bool = False
|
||||||
clear_milestone_id: bool = False
|
clear_milestone_id: bool = False
|
||||||
|
clear_roadmap_item_id: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{evidence_id}")
|
@router.get("/{evidence_id}")
|
||||||
|
|
@ -50,8 +52,10 @@ def update_evidence(
|
||||||
status=body.status,
|
status=body.status,
|
||||||
action_id=body.action_id,
|
action_id=body.action_id,
|
||||||
milestone_id=body.milestone_id,
|
milestone_id=body.milestone_id,
|
||||||
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
clear_action_id=body.clear_action_id,
|
clear_action_id=body.clear_action_id,
|
||||||
clear_milestone_id=body.clear_milestone_id,
|
clear_milestone_id=body.clear_milestone_id,
|
||||||
|
clear_roadmap_item_id=body.clear_roadmap_item_id,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ class EvidenceCreateRequest(BaseModel):
|
||||||
status: Literal["submitted", "accepted", "rejected"] = "submitted"
|
status: Literal["submitted", "accepted", "rejected"] = "submitted"
|
||||||
action_id: Optional[str] = None
|
action_id: Optional[str] = None
|
||||||
milestone_id: Optional[str] = None
|
milestone_id: Optional[str] = None
|
||||||
|
roadmap_item_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DecisionCreateRequest(BaseModel):
|
class DecisionCreateRequest(BaseModel):
|
||||||
|
|
@ -412,6 +413,7 @@ def create_initiative_evidence(
|
||||||
status=body.status,
|
status=body.status,
|
||||||
action_id=body.action_id,
|
action_id=body.action_id,
|
||||||
milestone_id=body.milestone_id,
|
milestone_id=body.milestone_id,
|
||||||
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
submitted_by_actor_id=ctx.actor_id,
|
submitted_by_actor_id=ctx.actor_id,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ EvidenceStatus = Literal["submitted", "accepted", "rejected"]
|
||||||
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
|
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
|
||||||
|
|
||||||
_EVIDENCE_COLUMNS = """
|
_EVIDENCE_COLUMNS = """
|
||||||
id, tenant_id, initiative_id, action_id, milestone_id, title, description,
|
id, tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
|
||||||
status, submitted_by_actor_id, created_at, updated_at
|
title, description, status, submitted_by_actor_id, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
"initiative_id",
|
"initiative_id",
|
||||||
"action_id",
|
"action_id",
|
||||||
"milestone_id",
|
"milestone_id",
|
||||||
|
"roadmap_item_id",
|
||||||
"submitted_by_actor_id",
|
"submitted_by_actor_id",
|
||||||
):
|
):
|
||||||
if result.get(key):
|
if result.get(key):
|
||||||
|
|
@ -72,6 +73,29 @@ def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _roadmap_item_in_initiative(
|
||||||
|
*, tenant_id: str, initiative_id: str, roadmap_item_id: str
|
||||||
|
) -> Optional[dict[str, str]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT ri.id, ri.item_type
|
||||||
|
FROM roadmap_items ri
|
||||||
|
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||||
|
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
|
||||||
|
""",
|
||||||
|
(roadmap_item_id, tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {"id": str(row["id"]), "item_type": row["item_type"]}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _milestone_in_initiative(
|
def _milestone_in_initiative(
|
||||||
*, tenant_id: str, initiative_id: str, milestone_id: str
|
*, tenant_id: str, initiative_id: str, milestone_id: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -93,7 +117,6 @@ def _milestone_in_initiative(
|
||||||
FROM roadmap_items ri
|
FROM roadmap_items ri
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||||
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
|
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
|
||||||
AND ri.item_type = 'milestone'
|
|
||||||
""",
|
""",
|
||||||
(milestone_id, tenant_id, initiative_id),
|
(milestone_id, tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
|
|
@ -102,6 +125,40 @@ def _milestone_in_initiative(
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_gate_links(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
milestone_id: Optional[str] = None,
|
||||||
|
roadmap_item_id: Optional[str] = None,
|
||||||
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Gibt (milestone_id, roadmap_item_id) für Evidence-Insert zurück."""
|
||||||
|
resolved_milestone = milestone_id
|
||||||
|
resolved_roadmap = roadmap_item_id
|
||||||
|
|
||||||
|
if roadmap_item_id:
|
||||||
|
item = _roadmap_item_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
roadmap_item_id=roadmap_item_id,
|
||||||
|
)
|
||||||
|
if not item:
|
||||||
|
raise ValueError("Plan-Element gehört nicht zum Vorhaben")
|
||||||
|
resolved_roadmap = item["id"]
|
||||||
|
if item["item_type"] == "milestone":
|
||||||
|
resolved_milestone = item["id"]
|
||||||
|
elif milestone_id:
|
||||||
|
if not _milestone_in_initiative(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
milestone_id=milestone_id,
|
||||||
|
):
|
||||||
|
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
||||||
|
resolved_roadmap = milestone_id
|
||||||
|
|
||||||
|
return resolved_milestone, resolved_roadmap
|
||||||
|
|
||||||
|
|
||||||
def create_evidence(
|
def create_evidence(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -111,6 +168,7 @@ def create_evidence(
|
||||||
status: EvidenceStatus = "submitted",
|
status: EvidenceStatus = "submitted",
|
||||||
action_id: Optional[str] = None,
|
action_id: Optional[str] = None,
|
||||||
milestone_id: Optional[str] = None,
|
milestone_id: Optional[str] = None,
|
||||||
|
roadmap_item_id: Optional[str] = None,
|
||||||
submitted_by_actor_id: Optional[str] = None,
|
submitted_by_actor_id: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -124,10 +182,12 @@ def create_evidence(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
|
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
|
||||||
):
|
):
|
||||||
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||||
if milestone_id and not _milestone_in_initiative(
|
milestone_id, roadmap_item_id = _resolve_gate_links(
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id, milestone_id=milestone_id
|
tenant_id=tenant_id,
|
||||||
):
|
initiative_id=initiative_id,
|
||||||
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
milestone_id=milestone_id,
|
||||||
|
roadmap_item_id=roadmap_item_id,
|
||||||
|
)
|
||||||
if submitted_by_actor_id and not _actor_in_tenant(
|
if submitted_by_actor_id and not _actor_in_tenant(
|
||||||
tenant_id=tenant_id, actor_id=submitted_by_actor_id
|
tenant_id=tenant_id, actor_id=submitted_by_actor_id
|
||||||
):
|
):
|
||||||
|
|
@ -139,10 +199,10 @@ def create_evidence(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO evidence (
|
INSERT INTO evidence (
|
||||||
tenant_id, initiative_id, action_id, milestone_id,
|
tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
|
||||||
title, description, status, submitted_by_actor_id
|
title, description, status, submitted_by_actor_id
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING {_EVIDENCE_COLUMNS}
|
RETURNING {_EVIDENCE_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -150,6 +210,7 @@ def create_evidence(
|
||||||
initiative_id,
|
initiative_id,
|
||||||
action_id,
|
action_id,
|
||||||
milestone_id,
|
milestone_id,
|
||||||
|
roadmap_item_id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
|
|
@ -221,8 +282,10 @@ def update_evidence(
|
||||||
status: Optional[EvidenceStatus] = None,
|
status: Optional[EvidenceStatus] = None,
|
||||||
action_id: Optional[str] = None,
|
action_id: Optional[str] = None,
|
||||||
milestone_id: Optional[str] = None,
|
milestone_id: Optional[str] = None,
|
||||||
|
roadmap_item_id: Optional[str] = None,
|
||||||
clear_action_id: bool = False,
|
clear_action_id: bool = False,
|
||||||
clear_milestone_id: bool = False,
|
clear_milestone_id: bool = False,
|
||||||
|
clear_roadmap_item_id: bool = False,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
|
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -255,17 +318,20 @@ def update_evidence(
|
||||||
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
|
||||||
updates.append("action_id = %s")
|
updates.append("action_id = %s")
|
||||||
params.append(action_id)
|
params.append(action_id)
|
||||||
if clear_milestone_id:
|
if clear_milestone_id or clear_roadmap_item_id:
|
||||||
updates.append("milestone_id = NULL")
|
updates.append("milestone_id = NULL")
|
||||||
elif milestone_id is not None:
|
updates.append("roadmap_item_id = NULL")
|
||||||
if not _milestone_in_initiative(
|
elif roadmap_item_id is not None or milestone_id is not None:
|
||||||
|
link_milestone, link_roadmap = _resolve_gate_links(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=existing["initiative_id"],
|
initiative_id=existing["initiative_id"],
|
||||||
milestone_id=milestone_id,
|
milestone_id=milestone_id,
|
||||||
):
|
roadmap_item_id=roadmap_item_id,
|
||||||
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
|
)
|
||||||
updates.append("milestone_id = %s")
|
updates.append("milestone_id = %s")
|
||||||
params.append(milestone_id)
|
params.append(link_milestone)
|
||||||
|
updates.append("roadmap_item_id = %s")
|
||||||
|
params.append(link_roadmap)
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return existing
|
return existing
|
||||||
|
|
|
||||||
|
|
@ -549,10 +549,10 @@ def verify_reached(
|
||||||
SELECT 1 FROM evidence
|
SELECT 1 FROM evidence
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
AND status = 'accepted'
|
AND status = 'accepted'
|
||||||
AND milestone_id = %s
|
AND (roadmap_item_id = %s OR milestone_id = %s)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id, item_id),
|
(tenant_id, initiative_id, item_id, item_id),
|
||||||
)
|
)
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
verify_reason = "evidence_accepted"
|
verify_reason = "evidence_accepted"
|
||||||
|
|
@ -562,10 +562,10 @@ def verify_reached(
|
||||||
SELECT 1 FROM reviews
|
SELECT 1 FROM reviews
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
AND status = 'completed'
|
AND status = 'completed'
|
||||||
AND (milestone_id = %s)
|
AND (roadmap_item_id = %s OR milestone_id = %s)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(tenant_id, initiative_id, item_id),
|
(tenant_id, initiative_id, item_id, item_id),
|
||||||
)
|
)
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
verify_reason = "review_completed"
|
verify_reason = "review_completed"
|
||||||
|
|
@ -584,8 +584,10 @@ def verify_reached(
|
||||||
verify_reason = "gate_override_decision"
|
verify_reason = "gate_override_decision"
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Verify fehlgeschlagen — accepted Evidence, completed Review "
|
"Verify fehlgeschlagen — am Plan-Element fehlt ein akzeptierter "
|
||||||
"oder gate_override Decision erforderlich"
|
"Nachweis (Evidence), ein abgeschlossenes Review oder eine "
|
||||||
|
"gate_override-Decision. Nachweis unter Plan einreichen oder auf "
|
||||||
|
"der Journey-Seite mit Plan-Element verknüpfen und auf Akzeptiert setzen."
|
||||||
)
|
)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def test_verify_reached_requires_evidence(client):
|
||||||
f"/api/initiatives/{initiative_id}/evidence",
|
f"/api/initiatives/{initiative_id}/evidence",
|
||||||
json={
|
json={
|
||||||
"title": "Nachweis",
|
"title": "Nachweis",
|
||||||
"milestone_id": item["id"],
|
"roadmap_item_id": item["id"],
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
},
|
},
|
||||||
headers=_auth(token),
|
headers=_auth(token),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.13.0-ap1.4"
|
APP_VERSION = "0.13.0-ap1.4"
|
||||||
DB_SCHEMA_VERSION = "010"
|
DB_SCHEMA_VERSION = "011"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,50 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { EVIDENCE_STATUSES, EVIDENCE_STATUS_LABELS } from '../constants/status.js'
|
import { EVIDENCE_STATUSES, EVIDENCE_STATUS_LABELS, ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
|
|
||||||
export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, onDelete, busy }) {
|
function planElementLabel(items, roadmapItemId) {
|
||||||
|
if (!roadmapItemId) return null
|
||||||
|
const item = items.find((i) => i.id === roadmapItemId)
|
||||||
|
if (!item) return 'Plan-Element'
|
||||||
|
const type = ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type
|
||||||
|
return `${type}: ${item.title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvidenceSection({
|
||||||
|
items,
|
||||||
|
roadmapItems = [],
|
||||||
|
canManage,
|
||||||
|
onCreate,
|
||||||
|
onUpdateStatus,
|
||||||
|
onDelete,
|
||||||
|
busy,
|
||||||
|
}) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
|
const [roadmapItemId, setRoadmapItemId] = useState('')
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!title.trim()) return
|
if (!title.trim()) return
|
||||||
await onCreate({ title: title.trim() })
|
await onCreate({
|
||||||
|
title: title.trim(),
|
||||||
|
roadmap_item_id: roadmapItemId || undefined,
|
||||||
|
})
|
||||||
setTitle('')
|
setTitle('')
|
||||||
|
setRoadmapItemId('')
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
<h2>Evidence</h2>
|
<h2>Evidence</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Für Gate-Verify am Plan-Element verknüpfen und Status Akzeptiert setzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -41,6 +67,22 @@ export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, on
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{roadmapItems.length > 0 && (
|
||||||
|
<label>
|
||||||
|
Plan-Element (optional)
|
||||||
|
<select
|
||||||
|
value={roadmapItemId}
|
||||||
|
onChange={(e) => setRoadmapItemId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— nicht zugeordnet —</option>
|
||||||
|
{roadmapItems.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}: {item.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
Anlegen
|
Anlegen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -55,6 +97,15 @@ export function EvidenceSection({ items, canManage, onCreate, onUpdateStatus, on
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
{item.description && <p className="list-item-desc">{item.description}</p>}
|
{item.description && <p className="list-item-desc">{item.description}</p>}
|
||||||
|
{(item.roadmap_item_id || item.milestone_id) && (
|
||||||
|
<p className="muted list-item-sub">
|
||||||
|
Plan:{' '}
|
||||||
|
{planElementLabel(
|
||||||
|
roadmapItems,
|
||||||
|
item.roadmap_item_id || item.milestone_id
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
<StatusBadge kind="evidence" status={item.status} />
|
<StatusBadge kind="evidence" status={item.status} />
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function RoadmapPlanSection({
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdateStatus,
|
onUpdateStatus,
|
||||||
onUpdateItem,
|
onUpdateItem,
|
||||||
onVerifyReached,
|
onVerifyWithEvidence,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -41,6 +41,7 @@ export function RoadmapPlanSection({
|
||||||
const [itemType, setItemType] = useState('milestone')
|
const [itemType, setItemType] = useState('milestone')
|
||||||
const [sequencingMode, setSequencingMode] = useState('sequential')
|
const [sequencingMode, setSequencingMode] = useState('sequential')
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [verifyTitles, setVerifyTitles] = useState({})
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -193,14 +194,41 @@ export function RoadmapPlanSection({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{['planned', 'active', 'at_risk'].includes(item.status) && (
|
{['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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onVerifyReached(item.id)}
|
onClick={() =>
|
||||||
|
onVerifyWithEvidence(
|
||||||
|
item.id,
|
||||||
|
verifyTitles[item.id]?.trim() || `Nachweis: ${item.title}`
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Verify → erreicht
|
Gate schließen
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,24 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleVerifyWithEvidence(itemId, evidenceTitle) {
|
||||||
|
setFormBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createInitiativeEvidence(id, {
|
||||||
|
title: evidenceTitle,
|
||||||
|
roadmap_item_id: itemId,
|
||||||
|
status: 'accepted',
|
||||||
|
})
|
||||||
|
await verifyRoadmapItemReached(itemId)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setFormBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleVerifyRoadmapItem(itemId) {
|
async function handleVerifyRoadmapItem(itemId) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -545,6 +563,7 @@ export function InitiativeOperationsProvider({ children }) {
|
||||||
handleRoadmapItemStatus,
|
handleRoadmapItemStatus,
|
||||||
handleUpdateRoadmapItem,
|
handleUpdateRoadmapItem,
|
||||||
handleVerifyRoadmapItem,
|
handleVerifyRoadmapItem,
|
||||||
|
handleVerifyWithEvidence,
|
||||||
handleDeleteRoadmapItem,
|
handleDeleteRoadmapItem,
|
||||||
handleCreateMilestone: handleCreateRoadmapItem,
|
handleCreateMilestone: handleCreateRoadmapItem,
|
||||||
handleMilestoneStatus: handleRoadmapItemStatus,
|
handleMilestoneStatus: handleRoadmapItemStatus,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function InitiativeJourneyPage() {
|
||||||
/>
|
/>
|
||||||
<EvidenceSection
|
<EvidenceSection
|
||||||
items={ops.evidenceItems}
|
items={ops.evidenceItems}
|
||||||
|
roadmapItems={ops.roadmapItems}
|
||||||
canManage={capabilities.has('kairo.evidence.manage')}
|
canManage={capabilities.has('kairo.evidence.manage')}
|
||||||
onCreate={ops.handleCreateEvidence}
|
onCreate={ops.handleCreateEvidence}
|
||||||
onUpdateStatus={ops.handleEvidenceStatus}
|
onUpdateStatus={ops.handleEvidenceStatus}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export function InitiativePlanPage() {
|
||||||
handleCreateRoadmapItem,
|
handleCreateRoadmapItem,
|
||||||
handleRoadmapItemStatus,
|
handleRoadmapItemStatus,
|
||||||
handleUpdateRoadmapItem,
|
handleUpdateRoadmapItem,
|
||||||
handleVerifyRoadmapItem,
|
handleVerifyWithEvidence,
|
||||||
handleDeleteRoadmapItem,
|
handleDeleteRoadmapItem,
|
||||||
error,
|
error,
|
||||||
} = useInitiativeOperations()
|
} = useInitiativeOperations()
|
||||||
|
|
@ -27,7 +27,7 @@ export function InitiativePlanPage() {
|
||||||
onCreate={handleCreateRoadmapItem}
|
onCreate={handleCreateRoadmapItem}
|
||||||
onUpdateStatus={handleRoadmapItemStatus}
|
onUpdateStatus={handleRoadmapItemStatus}
|
||||||
onUpdateItem={handleUpdateRoadmapItem}
|
onUpdateItem={handleUpdateRoadmapItem}
|
||||||
onVerifyReached={handleVerifyRoadmapItem}
|
onVerifyWithEvidence={handleVerifyWithEvidence}
|
||||||
onDelete={handleDeleteRoadmapItem}
|
onDelete={handleDeleteRoadmapItem}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1123,4 +1123,26 @@
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gate-verify-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-subtle, #e5e7eb);
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-verify-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-verify-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user