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:
@@ -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 & 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user