This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/sdk/DocumentUpload/DocumentUploadSection.tsx
BreakPilot Dev f09e24d52c refactor(admin-v2): Consolidate compliance/DSGVO pages into SDK pipeline
Remove duplicate compliance and DSGVO admin pages that have been superseded
by the unified SDK pipeline. Update navigation, sidebar, roles, and module
registry to reflect the new structure. Add DSFA corpus API proxy and
source-policy components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:26:05 +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