Kairo-Jinkendo/frontend/src/components/PlanIstPanel.jsx
Lars 064779e187
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
AP1.14: Plan-Snapshot und Plan/Ist-Overlay in Kontrolle.
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>
2026-07-11 08:10:15 +02:00

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