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
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>
401 lines
16 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
// 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>
|
|
)
|
|
}
|