feat(cra): Management-Fortschritts-Ansicht (Ticket-Status-Readback)
Liest den Lebenszyklus jedes Befunds (status + tracker_issue_url) aus dem Scanner zurück und rollt ihn zu einem Management-Bild auf: % erledigt, 4-Phasen (offen/in Arbeit/erledigt/ausgeschlossen), offenes Restrisiko nach Schweregrad, Fortschritt je CRA-Anforderung und eine Aufgaben-/Ticket-Tabelle mit Jira-Link. Neuer Endpoint GET/POST /api/v1/cra/progress (dünn → Service cra_progress, rein deterministisch, kein /assess-Schema-Drift). Frontend: ProgressView in Ebene 1 (CRACyberView), live je Scanner-Repo, sonst Demo-Status. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { CRADemo, Measure } from '../_hooks/useCRADemo'
|
||||
import { useCRAProgress } from '../_hooks/useCRAProgress'
|
||||
import { ManagementSummary } from './ManagementSummary'
|
||||
import { ProgressView } from './ProgressView'
|
||||
import { CyberSafetyHero } from './CyberSafetyHero'
|
||||
import { TechFindings } from './TechFindings'
|
||||
|
||||
export function CRACyberView({ data }: { data: CRADemo }) {
|
||||
export function CRACyberView({ data, scannerRepo = '' }: { data: CRADemo; scannerRepo?: string }) {
|
||||
const measuresById: Record<string, Measure> = Object.fromEntries(
|
||||
data.open_measures.map((m) => [m.id, m]),
|
||||
)
|
||||
const { progress, live: progressLive } = useCRAProgress(scannerRepo, data.findings)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -27,6 +30,9 @@ export function CRACyberView({ data }: { data: CRADemo }) {
|
||||
{/* Ebene 1 — Management: Risiko & Aufwand auf einen Blick */}
|
||||
<ManagementSummary data={data} measuresById={measuresById} />
|
||||
|
||||
{/* Ebene 1 — Projekt-Fortschritt: Ticket-Status zurückgelesen */}
|
||||
<ProgressView progress={progress} live={progressLive} />
|
||||
|
||||
{/* Ebene 2 — Safety × Cyber: das Alleinstellungsmerkmal */}
|
||||
<CyberSafetyHero data={data} />
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { CRAProgress, ProgressReq, ProgressTheme } from '../_hooks/useCRAProgress'
|
||||
import { RISK_BADGE, RISK_LABEL } from './cra-badges'
|
||||
|
||||
const PHASE_BADGE: Record<string, string> = {
|
||||
offen: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||
in_arbeit: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
erledigt: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
ausgeschlossen: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400',
|
||||
}
|
||||
const PHASE_BAR: Record<string, string> = {
|
||||
offen: 'bg-gray-300 dark:bg-gray-600',
|
||||
in_arbeit: 'bg-blue-500',
|
||||
erledigt: 'bg-emerald-500',
|
||||
}
|
||||
|
||||
function PhaseBadge({ phase, label }: { phase: string; label: string }) {
|
||||
return (
|
||||
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${PHASE_BADGE[phase] || PHASE_BADGE.offen}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Segmented completion bar over the actionable findings (excl. ausgeschlossen).
|
||||
function CompletionBar({ p }: { p: CRAProgress }) {
|
||||
const base = Math.max(1, p.actionable)
|
||||
const seg = (n: number) => `${(100 * n) / base}%`
|
||||
return (
|
||||
<div className="flex h-3 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div className={PHASE_BAR.erledigt} style={{ width: seg(p.by_phase.erledigt || 0) }} title={`Erledigt: ${p.by_phase.erledigt || 0}`} />
|
||||
<div className={PHASE_BAR.in_arbeit} style={{ width: seg(p.by_phase.in_arbeit || 0) }} title={`In Arbeit: ${p.by_phase.in_arbeit || 0}`} />
|
||||
<div className={PHASE_BAR.offen} style={{ width: seg(p.by_phase.offen || 0) }} title={`Offen: ${p.by_phase.offen || 0}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReqRow({ r }: { r: ProgressReq }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1 py-2 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{r.title || r.req_id}</span>
|
||||
<PhaseBadge phase={r.phase} label={r.phase_label} />
|
||||
{r.open_risk && (
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[11px] font-semibold ${RISK_BADGE[r.open_risk]}`}>
|
||||
{RISK_LABEL[r.open_risk]} offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex h-2 w-full max-w-md overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div className="bg-emerald-500" style={{ width: `${r.completion_pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 whitespace-nowrap">
|
||||
{r.erledigt}/{r.offen + r.in_arbeit + r.erledigt} erledigt
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeRow({ t }: { t: ProgressTheme }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-100 dark:border-gray-700/50 align-top">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{t.title}</div>
|
||||
{t.location && <div className="text-xs text-gray-400">{t.location}</div>}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-sm text-gray-700 dark:text-gray-200">{t.requirement_title || t.requirement}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${RISK_BADGE[t.risk_level] || RISK_BADGE.LOW}`}>
|
||||
{RISK_LABEL[t.risk_level] || t.risk_level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3"><PhaseBadge phase={t.phase} label={t.phase_label} /></td>
|
||||
<td className="py-2.5 px-3 text-sm">
|
||||
{t.has_ticket ? (
|
||||
<a href={t.tracker_url} target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:underline">
|
||||
Ticket öffnen ↗
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400">kein Ticket</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressView({ progress, live }: { progress: CRAProgress | null; live: boolean }) {
|
||||
if (!progress || progress.total === 0) return null
|
||||
const p = progress
|
||||
const repo = p.source?.repo_id
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">Projekt-Fortschritt</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Status je Befund — zurückgelesen aus den Tickets der Entwicklung.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs rounded px-2 py-1 ${live ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'}`}>
|
||||
{live ? `Live · Repo ${repo ? repo.slice(0, 8) : ''}` : 'Demo-Status'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline completion */}
|
||||
<div className="grid gap-4 md:grid-cols-[auto_1fr] items-center">
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-5xl font-bold text-emerald-600 dark:text-emerald-400 leading-none">{p.completion_pct}%</p>
|
||||
<p className="text-sm text-gray-500 mt-1">erledigt ({p.by_phase.erledigt || 0} von {p.actionable})</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CompletionBar p={p} />
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">● {p.by_phase.erledigt || 0} Erledigt</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">● {p.by_phase.in_arbeit || 0} In Arbeit</span>
|
||||
<span className="text-gray-500">● {p.by_phase.offen || 0} Offen</span>
|
||||
{p.by_phase.ausgeschlossen ? (
|
||||
<span className="text-slate-400">● {p.by_phase.ausgeschlossen} Ausgeschlossen</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What's left, by risk */}
|
||||
{p.open_count > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">Noch offen:</span>
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map((lvl) =>
|
||||
p.by_risk_open[lvl] ? (
|
||||
<span key={lvl} className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-sm font-semibold ${RISK_BADGE[lvl]}`}>
|
||||
{p.by_risk_open[lvl]} {RISK_LABEL[lvl]}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-requirement progress */}
|
||||
{p.requirements.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-1">Fortschritt je Anforderung</h4>
|
||||
<div>
|
||||
{p.requirements.map((r) => <ReqRow key={r.req_id} r={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks & tickets */}
|
||||
<div className="overflow-x-auto">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-1">Aufgaben & Tickets</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-500 border-b border-gray-200 dark:border-gray-700 text-left">
|
||||
<th className="py-2 px-3">Befund</th>
|
||||
<th className="py-2 px-3">Anforderung</th>
|
||||
<th className="py-2 px-3">Risiko</th>
|
||||
<th className="py-2 px-3">Status</th>
|
||||
<th className="py-2 px-3">Ticket</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.themes.map((t) => <ThemeRow key={t.finding_id} t={t} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user