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>
254 lines
7.6 KiB
JavaScript
254 lines
7.6 KiB
JavaScript
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>
|
|
)
|
|
}
|