Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 2m18s
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 3s
Test Suite / compose-smoke (push) Has been skipped
Revisionen mit Audit, getrennte Soll/Ist/Diff-Ansicht unter Kontrolle/Plan-Ist; API plan-ist-view und plan-snapshots. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
9.2 KiB
JavaScript
273 lines
9.2 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import {
|
|
getInitiativePlanIstView,
|
|
createInitiativePlanSnapshot,
|
|
} from '../api/roadmap.js'
|
|
import { gatePath } from '../utils/routes.js'
|
|
import {
|
|
CRITERION_STATUS_LABELS,
|
|
MILESTONE_STATUS_LABELS,
|
|
ROADMAP_ITEM_TYPE_LABELS,
|
|
} from '../constants/status.js'
|
|
import { LoadingState } from './LoadingState.jsx'
|
|
import { ErrorState } from './ErrorState.jsx'
|
|
import { Modal } from './Modal.jsx'
|
|
import { EmptyState } from './EmptyState.jsx'
|
|
|
|
const DIFF_KIND_LABELS = {
|
|
gate_added: 'Gate neu',
|
|
gate_removed: 'Gate entfernt',
|
|
gate_modified: 'Gate geändert',
|
|
edge_added: 'Kante neu',
|
|
edge_removed: 'Kante entfernt',
|
|
criterion_added: 'Kriterium neu',
|
|
criterion_removed: 'Kriterium entfernt',
|
|
criterion_modified: 'Kriterium geändert',
|
|
ist_gate_status: 'Ist-Status Gate',
|
|
ist_gate_at_risk: 'Gate gefährdet',
|
|
ist_criterion_waived: 'Kriterium ausgelassen',
|
|
ist_criterion_deferred: 'Kriterium verschoben',
|
|
ist_criterion_failed: 'Kriterium fehlgeschlagen',
|
|
}
|
|
|
|
function formatPct(ratio) {
|
|
if (ratio == null) return null
|
|
return `${Math.round(ratio * 100)}%`
|
|
}
|
|
|
|
export function PlanIstPanel({ initiativeId, canManage = false }) {
|
|
const [view, setView] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
const [tab, setTab] = useState('plan')
|
|
const [busy, setBusy] = useState(false)
|
|
const [showRevision, setShowRevision] = useState(false)
|
|
const [revisionReason, setRevisionReason] = useState('')
|
|
|
|
const load = useCallback(async () => {
|
|
if (!initiativeId) return
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
setView(await getInitiativePlanIstView(initiativeId))
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [initiativeId])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [load])
|
|
|
|
async function handleCreateRevision(e) {
|
|
e.preventDefault()
|
|
setBusy(true)
|
|
setError(null)
|
|
try {
|
|
await createInitiativePlanSnapshot(initiativeId, {
|
|
reason: revisionReason.trim(),
|
|
})
|
|
setRevisionReason('')
|
|
setShowRevision(false)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return <LoadingState message="Plan/Ist wird geladen…" />
|
|
if (error && !view) return <ErrorState message={error} onRetry={load} />
|
|
if (!view) return null
|
|
|
|
const planItems = view.plan?.items || []
|
|
const istItems = view.ist?.items || []
|
|
const diffs = view.diff || []
|
|
|
|
return (
|
|
<section className="card plan-ist-panel">
|
|
<div className="section-header">
|
|
<div>
|
|
<h2>Plan vs. Ist</h2>
|
|
<p className="section-lead muted">
|
|
Soll-Graph und Ist-Overlay parallel — Abweichungen sichtbar, Plan-Revision mit Audit.
|
|
</p>
|
|
{view.latest_snapshot ? (
|
|
<p className="muted plan-ist-panel__snapshot-meta">
|
|
Baseline: Revision {view.latest_snapshot.revision_number}
|
|
{view.latest_snapshot.reason ? ` — ${view.latest_snapshot.reason}` : ''}
|
|
</p>
|
|
) : (
|
|
<p className="muted plan-ist-panel__snapshot-meta">
|
|
Noch keine Plan-Revision — aktueller Plan = live. Revision festhalten vor größeren
|
|
Änderungen.
|
|
</p>
|
|
)}
|
|
</div>
|
|
{canManage && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => setShowRevision(true)}
|
|
disabled={busy}
|
|
>
|
|
Plan-Revision
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="error">{error}</p>}
|
|
|
|
<div className="plan-ist-panel__tabs" role="tablist" aria-label="Plan Ist Diff">
|
|
{['plan', 'ist', 'diff'].map((key) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={tab === key}
|
|
className={
|
|
'plan-ist-panel__tab' + (tab === key ? ' plan-ist-panel__tab--active' : '')
|
|
}
|
|
onClick={() => setTab(key)}
|
|
>
|
|
{key === 'plan' ? 'Plan (Soll)' : key === 'ist' ? 'Ist' : `Diff (${diffs.length})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'plan' && (
|
|
<div className="plan-ist-panel__pane">
|
|
{planItems.length === 0 && (
|
|
<EmptyState message="Kein Plan — Zielzustände unter Planen anlegen." />
|
|
)}
|
|
<ul className="item-list">
|
|
{planItems.map((item) => (
|
|
<li key={item.id} className="list-item card-list-item">
|
|
<div className="list-item-main">
|
|
<strong>
|
|
<Link to={gatePath(item.id)}>{item.title}</Link>
|
|
</strong>
|
|
<p className="muted list-item-sub">
|
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
|
{item.target_date ? ` · Ziel ${item.target_date}` : ''}
|
|
</p>
|
|
{item.goal_description && (
|
|
<p className="list-item-desc">{item.goal_description}</p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{view.plan_source === 'snapshot' && view.current_plan && (
|
|
<p className="muted plan-ist-panel__hint">
|
|
Live-Plan weicht von Revision ab — siehe Diff.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'ist' && (
|
|
<div className="plan-ist-panel__pane">
|
|
{view.ist?.graph_state?.initiative_fulfillment_ratio != null && (
|
|
<p className="plan-ist-panel__fulfillment muted">
|
|
Erfüllungsgrad:{' '}
|
|
{formatPct(view.ist.graph_state.initiative_fulfillment_ratio)}
|
|
</p>
|
|
)}
|
|
<ul className="item-list">
|
|
{istItems.map((item) => (
|
|
<li key={item.id} className="list-item card-list-item">
|
|
<div className="list-item-main">
|
|
<strong>
|
|
<Link to={gatePath(item.id)}>{item.title}</Link>
|
|
</strong>
|
|
<p className="muted list-item-sub">
|
|
{MILESTONE_STATUS_LABELS[item.status] || item.status}
|
|
{item.fulfillment_ratio != null
|
|
? ` · ${formatPct(item.fulfillment_ratio)} Kriterien`
|
|
: ''}
|
|
</p>
|
|
{item.criteria?.length > 0 && (
|
|
<ul className="plan-ist-panel__criteria muted">
|
|
{item.criteria.map((crit) => (
|
|
<li key={crit.id}>
|
|
{crit.title}:{' '}
|
|
{CRITERION_STATUS_LABELS[crit.status] || crit.status}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'diff' && (
|
|
<div className="plan-ist-panel__pane">
|
|
{diffs.length === 0 && (
|
|
<EmptyState message="Keine Abweichungen — Plan und Ist sind konsistent." />
|
|
)}
|
|
<ul className="item-list">
|
|
{diffs.map((entry, index) => (
|
|
<li key={`${entry.kind}-${entry.item_id}-${index}`} className="list-item card-list-item">
|
|
<div className="list-item-main">
|
|
<span className="status-pill plan-ist-panel__diff-kind">
|
|
{DIFF_KIND_LABELS[entry.kind] || entry.kind}
|
|
</span>
|
|
<strong>{entry.title || entry.message}</strong>
|
|
<p className="muted list-item-desc">{entry.message}</p>
|
|
</div>
|
|
{entry.item_id && (
|
|
<div className="list-item-meta">
|
|
<Link to={gatePath(entry.item_id)} className="btn btn-secondary btn-sm">
|
|
Gate
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<Modal open={showRevision} title="Plan-Revision festhalten" onClose={() => setShowRevision(false)}>
|
|
<form className="form workspace-form" onSubmit={handleCreateRevision}>
|
|
<p className="muted form-hint">
|
|
Speichert Topologie, Kanten und Kriterien-Definitionen als Soll-Baseline — Ist-Status
|
|
bleibt getrennt.
|
|
</p>
|
|
<label>
|
|
Begründung (optional)
|
|
<textarea
|
|
value={revisionReason}
|
|
onChange={(e) => setRevisionReason(e.target.value)}
|
|
rows={3}
|
|
maxLength={2000}
|
|
placeholder="z. B. Scope-Freeze vor Release 1"
|
|
/>
|
|
</label>
|
|
<div className="form-actions">
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
{busy ? 'Speichern …' : 'Revision anlegen'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setShowRevision(false)}
|
|
disabled={busy}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</section>
|
|
)
|
|
}
|