Files
Benjamin Boenisch 4435e7ea0a Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:28 +01:00

584 lines
21 KiB
TypeScript

'use client'
import React, { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
// =============================================================================
// TYPES
// =============================================================================
export interface UploadedDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
extractedVersion?: string
extractedContent?: ExtractedContent
status: 'uploading' | 'processing' | 'ready' | 'error'
error?: string
}
export interface ExtractedContent {
title?: string
version?: string
lastModified?: string
sections?: ExtractedSection[]
metadata?: Record<string, string>
}
export interface ExtractedSection {
title: string
content: string
type?: string
}
export interface DocumentUploadSectionProps {
/** Type of document being uploaded (tom, dsfa, vvt, loeschfristen, etc.) */
documentType: 'tom' | 'dsfa' | 'vvt' | 'loeschfristen' | 'consent' | 'policy' | 'custom'
/** Title displayed in the upload section */
title?: string
/** Description text */
description?: string
/** Accepted file types */
acceptedTypes?: string
/** Callback when document is uploaded and processed */
onDocumentProcessed?: (doc: UploadedDocument) => void
/** Callback to open document in workflow editor */
onOpenInEditor?: (doc: UploadedDocument) => void
/** Session ID for QR upload */
sessionId?: string
/** Custom CSS classes */
className?: string
}
// =============================================================================
// ICONS
// =============================================================================
const UploadIcon = () => (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
)
const QRIcon = () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
)
const DocumentIcon = () => (
<svg className="w-5 h-5" 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>
)
const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function detectVersionFromFilename(filename: string): string | undefined {
// Common version patterns: v1.0, V2.1, _v3, -v1.2.3, version-2
const patterns = [
/[vV](\d+(?:\.\d+)*)/,
/version[_-]?(\d+(?:\.\d+)*)/i,
/[_-]v?(\d+\.\d+(?:\.\d+)?)[_-]/,
]
for (const pattern of patterns) {
const match = filename.match(pattern)
if (match) {
return match[1]
}
}
return undefined
}
function suggestNextVersion(currentVersion?: string): string {
if (!currentVersion) return '1.0'
const parts = currentVersion.split('.').map(Number)
if (parts.length >= 2) {
parts[parts.length - 1] += 1
} else {
parts.push(1)
}
return parts.join('.')
}
// =============================================================================
// QR CODE MODAL
// =============================================================================
interface QRModalProps {
isOpen: boolean
onClose: () => void
sessionId: string
onFileUploaded?: (file: File) => void
}
function QRCodeModal({ isOpen, onClose, sessionId }: QRModalProps) {
const [uploadUrl, setUploadUrl] = useState('')
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
useEffect(() => {
if (!isOpen) return
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
// Hostname to IP mapping for local network
const hostnameToIP: Record<string, string> = {
'macmini': '192.168.178.100',
'macmini.local': '192.168.178.100',
}
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
if (baseUrl.includes(hostname)) {
baseUrl = baseUrl.replace(hostname, ip)
}
})
// Force HTTP for mobile access (SSL cert is for hostname, not IP)
// This is safe because it's only used on the local network
if (baseUrl.startsWith('https://')) {
baseUrl = baseUrl.replace('https://', 'http://')
}
const uploadPath = `/upload/sdk/${sessionId}`
const fullUrl = `${baseUrl}${uploadPath}`
setUploadUrl(fullUrl)
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(fullUrl)}`
setQrCodeUrl(qrApiUrl)
}, [isOpen, sessionId])
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(uploadUrl)
} catch (err) {
console.error('Copy failed:', err)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<QRIcon />
</div>
<div>
<h3 className="font-semibold text-gray-900">Mit Handy hochladen</h3>
<p className="text-sm text-gray-500">QR-Code scannen</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
<CloseIcon />
</button>
</div>
<div className="flex flex-col items-center">
<div className="p-4 bg-white border border-gray-200 rounded-xl">
{qrCodeUrl ? (
<img src={qrCodeUrl} alt="QR Code" className="w-[200px] h-[200px]" />
) : (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<p className="mt-4 text-center text-sm text-gray-600">
Scannen Sie den Code mit Ihrem Handy,<br />
um Dokumente hochzuladen.
</p>
<div className="mt-4 w-full">
<p className="text-xs text-gray-400 mb-2">Oder Link teilen:</p>
<div className="flex gap-2">
<input
type="text"
value={uploadUrl}
readOnly
className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg bg-gray-50"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Kopieren
</button>
</div>
</div>
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg w-full">
<p className="text-xs text-amber-800">
<strong>Hinweis:</strong> Ihr Handy muss im gleichen Netzwerk sein.
</p>
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function DocumentUploadSection({
documentType,
title,
description,
acceptedTypes = '.pdf,.docx,.doc',
onDocumentProcessed,
onOpenInEditor,
sessionId,
className = '',
}: DocumentUploadSectionProps) {
const router = useRouter()
const [isDragging, setIsDragging] = useState(false)
const [uploadedDocs, setUploadedDocs] = useState<UploadedDocument[]>([])
const [showQRModal, setShowQRModal] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const effectiveSessionId = sessionId || `sdk-${documentType}-${Date.now()}`
const defaultTitles: Record<string, string> = {
tom: 'Bestehende TOMs hochladen',
dsfa: 'Bestehende DSFA hochladen',
vvt: 'Bestehendes VVT hochladen',
loeschfristen: 'Bestehende Löschfristen hochladen',
consent: 'Bestehende Einwilligungsdokumente hochladen',
policy: 'Bestehende Richtlinie hochladen',
custom: 'Dokument hochladen',
}
const defaultDescriptions: Record<string, string> = {
tom: 'Laden Sie Ihre bestehenden technischen und organisatorischen Maßnahmen hoch. Wir erkennen die Version und zeigen Ihnen, was aktualisiert werden sollte.',
dsfa: 'Laden Sie Ihre bestehende Datenschutz-Folgenabschätzung hoch. Wir analysieren den Inhalt und schlagen Ergänzungen vor.',
vvt: 'Laden Sie Ihr bestehendes Verarbeitungsverzeichnis hoch. Wir erkennen die Struktur und helfen bei der Aktualisierung.',
loeschfristen: 'Laden Sie Ihre bestehenden Löschfristen-Dokumente hoch. Wir analysieren die Fristen und prüfen auf Vollständigkeit.',
consent: 'Laden Sie Ihre bestehenden Einwilligungsdokumente hoch.',
policy: 'Laden Sie Ihre bestehende Richtlinie hoch.',
custom: 'Laden Sie ein bestehendes Dokument hoch.',
}
const displayTitle = title || defaultTitles[documentType]
const displayDescription = description || defaultDescriptions[documentType]
// Drag & Drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const processFile = useCallback(async (file: File) => {
const docId = `doc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const newDoc: UploadedDocument = {
id: docId,
name: file.name,
type: file.type,
size: file.size,
uploadedAt: new Date(),
extractedVersion: detectVersionFromFilename(file.name),
status: 'uploading',
}
setUploadedDocs(prev => [...prev, newDoc])
setIsExpanded(true)
try {
// Upload to API
const formData = new FormData()
formData.append('file', file)
formData.append('documentType', documentType)
formData.append('sessionId', effectiveSessionId)
const response = await fetch('/api/sdk/v1/documents/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error('Upload fehlgeschlagen')
}
const result = await response.json()
const updatedDoc: UploadedDocument = {
...newDoc,
status: 'ready',
extractedVersion: result.extractedVersion || newDoc.extractedVersion,
extractedContent: result.extractedContent,
}
setUploadedDocs(prev => prev.map(d => d.id === docId ? updatedDoc : d))
if (onDocumentProcessed) {
onDocumentProcessed(updatedDoc)
}
} catch (error) {
setUploadedDocs(prev => prev.map(d =>
d.id === docId
? { ...d, status: 'error', error: error instanceof Error ? error.message : 'Fehler' }
: d
))
}
}, [documentType, effectiveSessionId, onDocumentProcessed])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files).filter(f =>
f.type === 'application/pdf' ||
f.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
f.type === 'application/msword'
)
files.forEach(processFile)
}, [processFile])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return
Array.from(e.target.files).forEach(processFile)
e.target.value = '' // Reset input
}, [processFile])
const removeDocument = useCallback((docId: string) => {
setUploadedDocs(prev => prev.filter(d => d.id !== docId))
}, [])
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
if (onOpenInEditor) {
onOpenInEditor(doc)
} else {
// Default: navigate to workflow editor
router.push(`/sdk/workflow?documentType=${documentType}&documentId=${doc.id}`)
}
}, [documentType, onOpenInEditor, router])
return (
<div className={`bg-white border border-gray-200 rounded-xl overflow-hidden ${className}`}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center text-blue-600">
<DocumentIcon />
</div>
<div className="text-left">
<h3 className="font-medium text-gray-900">{displayTitle}</h3>
<p className="text-sm text-gray-500">
{uploadedDocs.length > 0
? `${uploadedDocs.length} Dokument(e) hochgeladen`
: 'Optional - bestehende Dokumente importieren'
}
</p>
</div>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 pt-0 space-y-4">
<p className="text-sm text-gray-600">{displayDescription}</p>
{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative p-6 rounded-lg border-2 border-dashed transition-colors ${
isDragging
? 'border-purple-400 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<input
type="file"
accept={acceptedTypes}
multiple
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="text-center">
<div className="text-gray-400 mb-2">
<UploadIcon />
</div>
<p className="text-sm font-medium text-gray-700">
{isDragging ? 'Dateien hier ablegen' : 'Dateien hierher ziehen'}
</p>
<p className="text-xs text-gray-500 mt-1">
oder klicken zum Auswählen (PDF, DOCX)
</p>
</div>
</div>
{/* QR Upload Button */}
<button
onClick={() => setShowQRModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors"
>
<QRIcon />
<span>Mit Handy hochladen (QR-Code)</span>
</button>
{/* Uploaded Documents */}
{uploadedDocs.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Hochgeladene Dokumente:</p>
{uploadedDocs.map(doc => (
<div
key={doc.id}
className={`flex items-center gap-3 p-3 rounded-lg border ${
doc.status === 'error'
? 'bg-red-50 border-red-200'
: doc.status === 'ready'
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex-shrink-0">
{doc.status === 'uploading' || doc.status === 'processing' ? (
<div className="w-5 h-5 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
) : doc.status === 'ready' ? (
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center">
<CheckIcon />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs">!</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{doc.name}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{formatFileSize(doc.size)}</span>
{doc.extractedVersion && (
<>
<span></span>
<span className="text-purple-600">Version {doc.extractedVersion}</span>
<span></span>
<span className="text-green-600 font-medium">
Vorschlag: v{suggestNextVersion(doc.extractedVersion)}
</span>
</>
)}
</div>
{doc.error && (
<p className="text-xs text-red-600 mt-1">{doc.error}</p>
)}
</div>
<div className="flex items-center gap-1">
{doc.status === 'ready' && (
<button
onClick={() => handleOpenInEditor(doc)}
className="p-2 text-purple-600 hover:bg-purple-100 rounded-lg transition-colors"
title="Im Editor öffnen"
>
<EditIcon />
</button>
)}
<button
onClick={() => removeDocument(doc.id)}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Entfernen"
>
<CloseIcon />
</button>
</div>
</div>
))}
</div>
)}
{/* Extracted Content Preview */}
{uploadedDocs.some(d => d.status === 'ready' && d.extractedContent) && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 mb-2">Erkannte Inhalte</h4>
{uploadedDocs
.filter(d => d.status === 'ready' && d.extractedContent)
.map(doc => (
<div key={doc.id} className="text-sm text-blue-800">
{doc.extractedContent?.title && (
<p><strong>Titel:</strong> {doc.extractedContent.title}</p>
)}
{doc.extractedContent?.sections && (
<p><strong>Abschnitte:</strong> {doc.extractedContent.sections.length} gefunden</p>
)}
</div>
))
}
<button
onClick={() => uploadedDocs.find(d => d.status === 'ready') && handleOpenInEditor(uploadedDocs.find(d => d.status === 'ready')!)}
className="mt-3 w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Im Änderungsmodus öffnen
</button>
</div>
)}
</div>
)}
{/* QR Modal */}
<QRCodeModal
isOpen={showQRModal}
onClose={() => setShowQRModal(false)}
sessionId={effectiveSessionId}
/>
</div>
)
}
export default DocumentUploadSection