Kairo-Jinkendo/frontend/src/components/GateCriteriaSection.jsx
Lars bd64340ca1
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
AP1.4c: Gate-Checkliste statt Freitext-DoD.
Kriterien-Fortschritt in der Gate-Liste, Modal-UX fuer Checklisten und optionales Erst-Kriterium beim Anlegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:53:38 +02:00

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