Files
breakpilot-compliance/admin-compliance/components/sdk/compliance-scope/ScopeExportTab.tsx
Benjamin Admin cb48b8289e
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 46s
CI/CD / test-python-backend-compliance (push) Successful in 42s
CI/CD / test-python-document-crawler (push) Successful in 29s
CI/CD / test-python-dsms-gateway (push) Successful in 25s
CI/CD / deploy-hetzner (push) Failing after 2s
fix(sdk): Align scope types with engine output + project isolation + optional block progress
Type alignment (root cause of client-side crash):
- RiskFlag: id/title/description → severity/category/message/recommendation
- ScopeGap: id/title/recommendation/relatedDocuments → gapType/currentState/targetState/effort
- NextAction: id/priority:number/effortDays → actionType/priority:string/estimatedEffort
- ScopeReasoning: details → factors + impact
- TriggeredHardTrigger: {rule: HardTriggerRule} → flat fields (ruleId, description, etc.)
- All UI components updated to match engine output shape

Project isolation:
- Scope localStorage key now includes projectId (prevents data leak between projects)

Optional block progress:
- Blocks with only optional questions now show green checkmark when any question answered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:58:29 +01:00

401 lines
16 KiB
TypeScript

'use client'
import React, { useState, useCallback } from 'react'
import type { ScopeDecision, ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeExportTabProps {
decision?: ScopeDecision | null
answers?: ScopeProfilingAnswer[]
scopeState?: { decision: ScopeDecision | null; answers: ScopeProfilingAnswer[] }
onBackToDecision?: () => void
}
export function ScopeExportTab({ decision: decisionProp, answers: answersProp, scopeState, onBackToDecision }: ScopeExportTabProps) {
const decision = decisionProp ?? scopeState?.decision ?? null
const answers = answersProp ?? scopeState?.answers ?? []
// onBackToDecision is accepted but not used in this component (navigation handled by parent)
const [copiedMarkdown, setCopiedMarkdown] = useState(false)
const handleDownloadJSON = useCallback(() => {
if (!decision) return
const dataStr = JSON.stringify(decision, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `compliance-scope-decision-${new Date().toISOString().split('T')[0]}.json`
link.click()
URL.revokeObjectURL(url)
}, [decision])
const handleDownloadCSV = useCallback(() => {
if (!decision || !decision.requiredDocuments) return
const headers = ['Typ', 'Priorität', 'Aufwand (Stunden)', 'Pflicht', 'Hard-Trigger']
const rows = decision.requiredDocuments.map((doc) => [
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
doc.priority,
String(doc.estimatedEffort || 0),
doc.requirement === 'mandatory' ? 'Ja' : 'Nein',
doc.triggeredBy.length > 0 ? 'Ja' : 'Nein',
])
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
const dataBlob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `compliance-scope-documents-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}, [decision])
const generateMarkdownSummary = useCallback(() => {
if (!decision) return ''
let markdown = `# Compliance Scope Entscheidung\n\n`
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
markdown += `## Einstufung\n\n`
markdown += `**Level:** ${decision.determinedLevel} - ${DEPTH_LEVEL_LABELS[decision.determinedLevel]}\n\n`
if (decision.reasoning && decision.reasoning.length > 0) {
markdown += `**Begründung:** ${decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}\n\n`
}
if (decision.scores) {
markdown += `## Scores\n\n`
markdown += `- **Risiko-Score:** ${decision.scores.risk_score}/100\n`
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexity_score}/100\n`
markdown += `- **Assurance-Score:** ${decision.scores.assurance_need}/100\n`
markdown += `- **Gesamt-Score:** ${decision.scores.composite_score}/100\n\n`
}
if (decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0) {
markdown += `## Aktive Hard-Trigger\n\n`
decision.triggeredHardTriggers.forEach((trigger) => {
markdown += `- **${trigger.description}**\n`
markdown += ` - ${trigger.description}\n`
if (trigger.legalReference) {
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
}
})
markdown += `\n`
}
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
markdown += `## Erforderliche Dokumente\n\n`
markdown += `| Typ | Priorität | Aufwand (h) | Pflicht | Hard-Trigger |\n`
markdown += `|-----|-----------|-------------|---------|-------------|\n`
decision.requiredDocuments.forEach((doc) => {
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.priority} | ${
doc.estimatedEffort || 0
} | ${doc.requirement === 'mandatory' ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n`
})
markdown += `\n`
}
if (decision.riskFlags && decision.riskFlags.length > 0) {
markdown += `## Risiko-Flags\n\n`
decision.riskFlags.forEach((flag) => {
markdown += `### ${flag.message} (${flag.severity})\n\n`
if (flag.legalReference) markdown += `Rechtsgrundlage: ${flag.legalReference}\n\n`
markdown += `**Empfehlung:** ${flag.recommendation}\n\n`
})
}
if (decision.gaps && decision.gaps.length > 0) {
markdown += `## Gap-Analyse\n\n`
decision.gaps.forEach((gap) => {
markdown += `### ${gap.description} (${gap.severity})\n\n`
markdown += `- **Ist:** ${gap.currentState}\n`
markdown += `- **Soll:** ${gap.targetState}\n`
markdown += `- **Aufwand:** ~${gap.effort}h\n\n`
})
}
if (decision.nextActions && decision.nextActions.length > 0) {
markdown += `## Nächste Schritte\n\n`
decision.nextActions.forEach((action, idx) => {
markdown += `${idx + 1}. **${action.title}**\n`
markdown += ` ${action.description}\n`
if (action.estimatedEffort) {
markdown += ` Aufwand: ~${action.estimatedEffort}h\n`
}
markdown += `\n`
})
}
return markdown
}, [decision])
const handleCopyMarkdown = useCallback(() => {
const markdown = generateMarkdownSummary()
navigator.clipboard.writeText(markdown).then(() => {
setCopiedMarkdown(true)
setTimeout(() => setCopiedMarkdown(false), 2000)
})
}, [generateMarkdownSummary])
/** Simple markdown-to-HTML converter for print view */
const markdownToHtml = useCallback((md: string): string => {
let html = md
// Escape HTML entities
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Tables
const lines = html.split('\n')
let inTable = false
const result: string[] = []
for (const line of lines) {
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
if (line.includes('---')) continue // separator row
const cells = line.split('|').filter(c => c.trim() !== '')
if (!inTable) {
result.push('<table><thead><tr>')
cells.forEach(c => result.push(`<th>${c.trim()}</th>`))
result.push('</tr></thead><tbody>')
inTable = true
} else {
result.push('<tr>')
cells.forEach(c => result.push(`<td>${c.trim()}</td>`))
result.push('</tr>')
}
} else {
if (inTable) {
result.push('</tbody></table>')
inTable = false
}
// List items
if (line.trim().startsWith('- ')) {
result.push(`<li>${line.trim().slice(2)}</li>`)
} else if (/^\d+\.\s/.test(line.trim())) {
result.push(`<li>${line.trim().replace(/^\d+\.\s/, '')}</li>`)
} else if (line.trim() === '') {
result.push('<br/>')
} else {
result.push(`<p>${line}</p>`)
}
}
}
if (inTable) result.push('</tbody></table>')
return result.join('\n')
}, [])
const handlePrintView = useCallback(() => {
if (!decision) return
const markdown = generateMarkdownSummary()
const renderedHtml = markdownToHtml(markdown)
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Compliance Scope Entscheidung</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}
h1 { color: #7c3aed; border-bottom: 3px solid #7c3aed; padding-bottom: 10px; }
h2 { color: #5b21b6; margin-top: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; }
h3 { color: #4c1d95; margin-top: 20px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #d1d5db; padding: 12px; text-align: left; }
th { background-color: #f3f4f6; font-weight: 600; }
ul { list-style-type: disc; padding-left: 20px; }
li { margin: 8px 0; }
p { margin: 4px 0; }
@media print {
body { margin: 20px; }
h1, h2, h3 { page-break-after: avoid; }
table { page-break-inside: avoid; }
}
</style>
</head>
<body>
${renderedHtml}
</body>
</html>
`
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 250)
}
}, [decision, generateMarkdownSummary, markdownToHtml])
if (!decision) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Daten zum Export</h3>
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
</div>
)
}
return (
<div className="space-y-6">
{/* JSON Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">JSON Export</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Exportieren Sie die vollständige Entscheidung als strukturierte JSON-Datei für weitere Verarbeitung oder
Archivierung.
</p>
<button
onClick={handleDownloadJSON}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
>
JSON herunterladen
</button>
</div>
</div>
</div>
{/* CSV Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">CSV Export</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Exportieren Sie die Liste der erforderlichen Dokumente als CSV-Datei für Excel, Google Sheets oder andere
Tabellenkalkulationen.
</p>
<button
onClick={handleDownloadCSV}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium text-sm"
>
CSV herunterladen
</button>
</div>
</div>
</div>
{/* Markdown Summary */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">Markdown-Zusammenfassung</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Strukturierte Zusammenfassung im Markdown-Format für Dokumentation oder Berichte.
</p>
<textarea
readOnly
value={generateMarkdownSummary()}
className="w-full h-64 px-4 py-3 border border-gray-300 rounded-lg font-mono text-sm text-gray-700 resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<button
onClick={handleCopyMarkdown}
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm"
>
{copiedMarkdown ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{/* Print View */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">Druckansicht</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Öffnen Sie eine druckfreundliche HTML-Ansicht der Entscheidung in einem neuen Fenster.
</p>
<button
onClick={handlePrintView}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium text-sm"
>
Druckansicht öffnen
</button>
</div>
</div>
</div>
{/* Export Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h4 className="text-sm font-semibold text-blue-900 mb-1">Export-Hinweise</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> JSON-Exporte enthalten alle Daten und können wieder importiert werden</li>
<li> CSV-Exporte sind ideal für Tabellenkalkulation und Aufwandsschätzungen</li>
<li> Markdown eignet sich für Dokumentation und Berichte</li>
<li> Die Druckansicht ist optimiert für PDF-Export über den Browser</li>
</ul>
</div>
</div>
</div>
</div>
)
}