feat(agent): Abstellmaßnahmen + Ticket-Formulierung (Schritt 3)

RemediationPlan: aus den offenen Punkten (result.results, Haupt-Engine) je
Finding eine Massnahme + fertigen Ticket-Text ableiten, nach Prioritaet
sortiert, mit Kopieren + JSON-Export als Uebergabe. SCOPE: BreakPilot
formuliert nur — Ticketsystem/Jira/Feedback-Loop baut ein anderes Team.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-11 00:12:35 +02:00
parent 5da20af4fd
commit bb9aacc3d3
3 changed files with 182 additions and 0 deletions
@@ -13,6 +13,7 @@ import React, { useState } from 'react'
import { ChecklistView, DOC_TYPE_LABELS, type DocResult } from './ChecklistView' import { ChecklistView, DOC_TYPE_LABELS, type DocResult } from './ChecklistView'
import { DocResultView } from './DocResultView' import { DocResultView } from './DocResultView'
import { MigrationPanel } from './MigrationPanel' import { MigrationPanel } from './MigrationPanel'
import { RemediationPlan } from './RemediationPlan'
import { ResultSummary } from './ResultSummary' import { ResultSummary } from './ResultSummary'
export function ComplianceResultTabs({ results }: { results: any }) { export function ComplianceResultTabs({ results }: { results: any }) {
@@ -147,6 +148,9 @@ export function ComplianceResultTabs({ results }: { results: any }) {
<DocResultView doc={docs[Number(active)]} /> <DocResultView doc={docs[Number(active)]} />
) : null} ) : null}
{/* Abstellmaßnahmen + Ticket-Formulierung (Übergabe an anderes Team) */}
<RemediationPlan results={results} />
{/* Check-Footer (themenübergreifend) */} {/* Check-Footer (themenübergreifend) */}
{results.email_status && ( {results.email_status && (
<div className="text-xs text-gray-500 flex items-center gap-2 border-t border-gray-100 pt-3"> <div className="text-xs text-gray-500 flex items-center gap-2 border-t border-gray-100 pt-3">
@@ -0,0 +1,140 @@
'use client'
/**
* RemediationPlan — Abstellmaßnahmen + Ticket-Formulierung.
*
* Aus den offenen Punkten (result.results, Haupt-Engine) je Finding eine
* Maßnahme + einen fertigen Ticket-Text ableiten und übergabebereit machen
* (Kopieren / JSON-Export). SCOPE: BreakPilot formuliert NUR — Ticketsystem,
* Jira-Sync und Feedback-Loop baut ein anderes Team. Keine zweite Engine.
*/
import React, { useState } from 'react'
import { DOC_TYPE_LABELS, type DocResult } from './ChecklistView'
type Priority = 'Hoch' | 'Mittel' | 'Niedrig'
interface Remediation {
docType: string
docLabel: string
checkLabel: string
action: string
ticketTitle: string
ticketBody: string
priority: Priority
}
const PRIO_RANK: Record<Priority, number> = { Hoch: 0, Mittel: 1, Niedrig: 2 }
const PRIO_COLOR: Record<Priority, string> = {
Hoch: 'bg-red-100 text-red-700',
Mittel: 'bg-amber-100 text-amber-700',
Niedrig: 'bg-blue-100 text-blue-700',
}
function toPriority(sev: string): Priority {
const s = (sev || '').toUpperCase()
if (s === 'HIGH' || s === 'CRITICAL') return 'Hoch'
if (s === 'MEDIUM') return 'Mittel'
return 'Niedrig'
}
function buildRemediations(docs: DocResult[]): Remediation[] {
const out: Remediation[] = []
for (const d of docs) {
if (d.error) continue
const docLabel = DOC_TYPE_LABELS[d.doc_type] || d.doc_type
const failed = d.checks.filter(
c => !c.passed && !c.skipped && c.severity !== 'INFO',
)
for (const c of failed) {
const action = c.hint || `${c.label} im ${docLabel} ergänzen.`
out.push({
docType: d.doc_type,
docLabel,
checkLabel: c.label,
action,
ticketTitle: `Compliance: ${docLabel} ${c.label}`,
ticketBody:
`Dokument: ${docLabel}\nPrüfpunkt: ${c.label}\n` +
`Status: nicht erfüllt\nMaßnahme: ${action}`,
priority: toPriority(c.severity),
})
}
}
return out.sort((a, b) => PRIO_RANK[a.priority] - PRIO_RANK[b.priority])
}
export function RemediationPlan({ results }: { results: any }) {
const items = buildRemediations(results.results || [])
const [copied, setCopied] = useState<number | null>(null)
if (items.length === 0) {
return (
<div className="border rounded-lg p-4 text-sm text-green-700 bg-green-50">
Keine offenen Pflichtangaben kein Handlungsbedarf.
</div>
)
}
function copyTicket(i: number, body: string) {
navigator.clipboard?.writeText(body)
setCopied(i)
window.setTimeout(() => setCopied(null), 1500)
}
function exportAll() {
const payload = items.map(it => ({
title: it.ticketTitle,
body: it.ticketBody,
priority: it.priority,
doc_type: it.docType,
}))
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'breakpilot-tickets.json'
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 border-b flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
Abstellmaßnahmen &amp; Tickets ({items.length})
</h3>
<button
onClick={exportAll}
className="text-xs px-2.5 py-1 rounded border border-gray-200 hover:bg-gray-100 text-gray-600"
>
Alle als JSON exportieren
</button>
</div>
<div className="divide-y divide-gray-100">
{items.map((it, i) => (
<div key={i} className="px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${PRIO_COLOR[it.priority]}`}>
{it.priority}
</span>
<span className="text-sm font-medium text-gray-800">
{it.docLabel}: {it.checkLabel}
</span>
</div>
<div className="text-xs text-gray-600">{it.action}</div>
<button
onClick={() => copyTicket(i, it.ticketBody)}
className="text-xs px-2 py-1 rounded bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100"
>
{copied === i ? 'Kopiert ✓' : 'Ticket-Text kopieren'}
</button>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { RemediationPlan } from '../RemediationPlan'
describe('RemediationPlan', () => {
it('leitet Maßnahmen nur aus echten offenen Punkten ab', () => {
const results = {
results: [
{ doc_type: 'impressum', error: '', completeness_pct: 50, checks: [
{ id: 'a', label: 'Registernummer', passed: false, severity: 'HIGH', matched_text: '', level: 1, hint: 'HRB ergänzen' },
{ id: 'b', label: 'Telefon', passed: false, severity: 'MEDIUM', matched_text: '', level: 1 },
{ id: 'c', label: 'OK-Feld', passed: true, severity: 'HIGH', matched_text: 'x', level: 1 },
{ id: 'd', label: 'Info-Hinweis', passed: false, severity: 'INFO', matched_text: '', level: 1 },
] },
],
}
render(<RemediationPlan results={results} />)
// 2 Maßnahmen (HIGH + MEDIUM); OK + INFO ausgeschlossen
expect(screen.getByText(/Abstellmaßnahmen & Tickets \(2\)/)).toBeInTheDocument()
expect(screen.getByText(/Registernummer/)).toBeInTheDocument()
expect(screen.getByText('HRB ergänzen')).toBeInTheDocument() // hint = Maßnahme
expect(screen.queryByText(/Info-Hinweis/)).not.toBeInTheDocument()
expect(screen.queryByText(/OK-Feld/)).not.toBeInTheDocument()
})
it('zeigt Erfolg, wenn keine offenen Punkte', () => {
const results = {
results: [
{ doc_type: 'impressum', error: '', completeness_pct: 100, checks: [
{ id: 'a', label: 'X', passed: true, severity: 'HIGH', matched_text: 'x', level: 1 },
] },
],
}
render(<RemediationPlan results={results} />)
expect(screen.getByText(/kein Handlungsbedarf/)).toBeInTheDocument()
})
})