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

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:
Lars 2026-07-11 07:53:38 +02:00
parent dbe6f4f684
commit bd64340ca1
10 changed files with 509 additions and 198 deletions

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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

View File

@ -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',

View 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>
)
}

View 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>
)
}

View File

@ -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

View File

@ -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} />

View File

@ -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) && (