'use client' /** * Document Workflow Page (SDK Version) * * Split-view editor for legal documents with synchronized scrolling: * - Left: Current published version (read-only) * - Right: Draft/new version (editable) * - Approval workflow: Draft -> Review -> Approved -> Published * - DOCX upload with Word conversion * - Rich text editor with formatting toolbar */ import { useState, useEffect, useRef, useCallback } from 'react' import { useSDK } from '@/lib/sdk' import StepHeader from '@/components/sdk/StepHeader/StepHeader' // Types interface Document { id: string type: string name: string description: string mandatory: boolean created_at: string updated_at: string } interface Version { id: string document_id: string version: string language: string title: string content: string summary?: string status: 'draft' | 'review' | 'approved' | 'published' | 'archived' | 'rejected' created_at: string updated_at?: string created_by?: string approved_by?: string approved_at?: string rejection_reason?: string } interface ApprovalHistoryItem { action: string approver: string comment: string created_at: string } const STATUS_LABELS: Record = { draft: { label: 'Entwurf', color: 'bg-yellow-100 text-yellow-700' }, review: { label: 'In Pruefung', color: 'bg-blue-100 text-blue-700' }, approved: { label: 'Freigegeben', color: 'bg-green-100 text-green-700' }, published: { label: 'Veroeffentlicht', color: 'bg-emerald-100 text-emerald-700' }, archived: { label: 'Archiviert', color: 'bg-slate-100 text-slate-700' }, rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' }, } const DOCUMENT_TYPES: Record = { terms: 'AGB', privacy: 'Datenschutzerklaerung', cookies: 'Cookie-Richtlinie', community_guidelines: 'Community-Richtlinien', imprint: 'Impressum', } export default function WorkflowPage() { const { state } = useSDK() const [documents, setDocuments] = useState([]) const [versions, setVersions] = useState([]) const [selectedDocument, setSelectedDocument] = useState(null) const [currentVersion, setCurrentVersion] = useState(null) const [draftVersion, setDraftVersion] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [editedContent, setEditedContent] = useState('') const [editedTitle, setEditedTitle] = useState('') const [editedSummary, setEditedSummary] = useState('') const [showHistory, setShowHistory] = useState(false) const [approvalHistory, setApprovalHistory] = useState([]) const [approvalComment, setApprovalComment] = useState('') const [showApprovalModal, setShowApprovalModal] = useState<'approve' | 'reject' | null>(null) const [showCompareView, setShowCompareView] = useState(false) const [uploading, setUploading] = useState(false) const [uploadError, setUploadError] = useState(null) const [showNewDocModal, setShowNewDocModal] = useState(false) const [newDocForm, setNewDocForm] = useState({ type: 'privacy_policy', name: '', description: '' }) const [creatingDoc, setCreatingDoc] = useState(false) // Refs for synchronized scrolling const leftPanelRef = useRef(null) const rightPanelRef = useRef(null) const editorRef = useRef(null) const fileInputRef = useRef(null) const isScrolling = useRef(false) useEffect(() => { loadDocuments() }, []) useEffect(() => { if (selectedDocument) { loadVersions(selectedDocument.id) } }, [selectedDocument]) // Synchronized scrolling setup const setupSyncScroll = useCallback(() => { const leftPanel = leftPanelRef.current const rightPanel = rightPanelRef.current if (!leftPanel || !rightPanel) return const handleLeftScroll = () => { if (isScrolling.current) return isScrolling.current = true const leftScrollPercent = leftPanel.scrollTop / (leftPanel.scrollHeight - leftPanel.clientHeight || 1) const rightMaxScroll = rightPanel.scrollHeight - rightPanel.clientHeight rightPanel.scrollTop = leftScrollPercent * rightMaxScroll setTimeout(() => { isScrolling.current = false }, 10) } const handleRightScroll = () => { if (isScrolling.current) return isScrolling.current = true const rightScrollPercent = rightPanel.scrollTop / (rightPanel.scrollHeight - rightPanel.clientHeight || 1) const leftMaxScroll = leftPanel.scrollHeight - leftPanel.clientHeight leftPanel.scrollTop = rightScrollPercent * leftMaxScroll setTimeout(() => { isScrolling.current = false }, 10) } leftPanel.addEventListener('scroll', handleLeftScroll) rightPanel.addEventListener('scroll', handleRightScroll) return () => { leftPanel.removeEventListener('scroll', handleLeftScroll) rightPanel.removeEventListener('scroll', handleRightScroll) } }, []) useEffect(() => { const cleanup = setupSyncScroll() return cleanup }, [setupSyncScroll, currentVersion, draftVersion]) const loadDocuments = async () => { setLoading(true) try { const res = await fetch('/api/admin/consent/documents') if (res.ok) { const data = await res.json() setDocuments(data.documents || []) if (data.documents?.length > 0 && !selectedDocument) { setSelectedDocument(data.documents[0]) } } } catch { setError('Fehler beim Laden der Dokumente') } finally { setLoading(false) } } const loadVersions = async (docId: string) => { try { const res = await fetch(`/api/admin/consent/documents/${docId}/versions`) if (res.ok) { const data = await res.json() const versionList = Array.isArray(data) ? data : (data.versions || []) setVersions(versionList) const published = versionList.find((v: Version) => v.status === 'published') setCurrentVersion(published || null) const draft = versionList.find((v: Version) => v.status === 'draft' || v.status === 'review' || v.status === 'approved' ) if (draft) { setDraftVersion(draft) setEditedContent(draft.content) setEditedTitle(draft.title) setEditedSummary(draft.summary || '') } else { setDraftVersion(null) setEditedContent(published?.content || '') setEditedTitle(published?.title || '') setEditedSummary(published?.summary || '') } } } catch { setError('Fehler beim Laden der Versionen') } } // Rich text editor functions const formatDoc = (cmd: string, value: string | null = null) => { if (editorRef.current) { editorRef.current.focus() document.execCommand(cmd, false, value || undefined) updateEditorContent() } } const formatBlock = (tag: string) => { if (editorRef.current) { editorRef.current.focus() document.execCommand('formatBlock', false, `<${tag}>`) updateEditorContent() } } const insertLink = () => { const url = prompt('Link-URL eingeben:', 'https://') if (url && editorRef.current) { editorRef.current.focus() document.execCommand('createLink', false, url) updateEditorContent() } } const updateEditorContent = () => { if (editorRef.current) { setEditedContent(editorRef.current.innerHTML) } } // Word document upload const handleWordUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return setUploading(true) const formData = new FormData() formData.append('file', file) try { const response = await fetch('/api/admin/consent/versions/upload-word', { method: 'POST', body: formData }) if (response.ok) { const data = await response.json() if (editorRef.current) { editorRef.current.innerHTML = data.html || '

Konvertierung fehlgeschlagen

' setEditedContent(editorRef.current.innerHTML) } } else { const errorData = await response.json().catch(() => ({})) setUploadError('Fehler beim Importieren: ' + (errorData.detail || 'Unbekannter Fehler')) } } catch (e) { setUploadError('Fehler beim Hochladen: ' + (e instanceof Error ? e.message : 'Unbekannter Fehler')) } finally { setUploading(false) if (fileInputRef.current) { fileInputRef.current.value = '' } } } // Clean Word HTML on paste const handlePaste = (e: React.ClipboardEvent) => { const clipboardData = e.clipboardData const html = clipboardData.getData('text/html') if (html) { e.preventDefault() const cleanHtml = cleanWordHtml(html) document.execCommand('insertHTML', false, cleanHtml) updateEditorContent() } } const cleanWordHtml = (html: string): string => { let cleaned = html cleaned = cleaned.replace(/\s*mso-[^:]+:[^;]+;?/gi, '') cleaned = cleaned.replace(/\s*style="[^"]*"/gi, '') cleaned = cleaned.replace(/\s*class="[^"]*"/gi, '') cleaned = cleaned.replace(/<\/o:p>/gi, '') cleaned = cleaned.replace(/<\/?o:[^>]*>/gi, '') cleaned = cleaned.replace(/<\/?w:[^>]*>/gi, '') cleaned = cleaned.replace(/<\/?m:[^>]*>/gi, '') cleaned = cleaned.replace(/]*>\s*<\/span>/gi, '') return cleaned } const createNewDraft = async () => { if (!selectedDocument) return setSaving(true) try { const nextVersion = getNextVersionNumber() const res = await fetch('/api/admin/consent/versions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_id: selectedDocument.id, version: nextVersion, language: 'de', title: editedTitle || currentVersion?.title || selectedDocument.name, content: editedContent || currentVersion?.content || '', summary: editedSummary || currentVersion?.summary || '', }), }) if (res.ok) { await loadVersions(selectedDocument.id) } else { const err = await res.json() setError(err.error || 'Fehler beim Erstellen des Entwurfs') } } catch { setError('Fehler beim Erstellen des Entwurfs') } finally { setSaving(false) } } const saveDraft = async () => { if (!draftVersion || draftVersion.status !== 'draft') return setSaving(true) try { const res = await fetch(`/api/admin/consent/versions/${draftVersion.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: editedTitle, content: editedContent, summary: editedSummary, }), }) if (res.ok) { await loadVersions(selectedDocument!.id) } else { const err = await res.json() setError(err.error || 'Fehler beim Speichern') } } catch { setError('Fehler beim Speichern') } finally { setSaving(false) } } const submitForReview = async () => { if (!draftVersion) return setSaving(true) try { const res = await fetch(`/api/admin/consent/versions/${draftVersion.id}/submit-review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) if (res.ok) { await loadVersions(selectedDocument!.id) } else { const err = await res.json() setError(err.error || 'Fehler beim Einreichen') } } catch { setError('Fehler beim Einreichen zur Pruefung') } finally { setSaving(false) } } const approveVersion = async () => { if (!draftVersion) return setSaving(true) try { const res = await fetch(`/api/admin/consent/versions/${draftVersion.id}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: approvalComment }), }) if (res.ok) { setShowApprovalModal(null) setApprovalComment('') await loadVersions(selectedDocument!.id) } else { const err = await res.json() setError(err.error || 'Fehler bei der Freigabe') } } catch { setError('Fehler bei der Freigabe') } finally { setSaving(false) } } const rejectVersion = async () => { if (!draftVersion) return setSaving(true) try { const res = await fetch(`/api/admin/consent/versions/${draftVersion.id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: approvalComment }), }) if (res.ok) { setShowApprovalModal(null) setApprovalComment('') await loadVersions(selectedDocument!.id) } else { const err = await res.json() setError(err.error || 'Fehler bei der Ablehnung') } } catch { setError('Fehler bei der Ablehnung') } finally { setSaving(false) } } const publishVersion = async () => { if (!draftVersion || draftVersion.status !== 'approved') return if (!confirm('Version wirklich veroeffentlichen? Die aktuelle Version wird archiviert.')) return setSaving(true) try { const res = await fetch(`/api/admin/consent/versions/${draftVersion.id}/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) if (res.ok) { await loadVersions(selectedDocument!.id) } else { const err = await res.json() setError(err.error || 'Fehler beim Veroeffentlichen') } } catch { setError('Fehler beim Veroeffentlichen') } finally { setSaving(false) } } const createDocument = async () => { if (!newDocForm.name.trim()) return setCreatingDoc(true) try { const res = await fetch('/api/admin/consent/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newDocForm), }) if (res.ok) { const newDoc: Document = await res.json() setDocuments(prev => [newDoc, ...prev]) setSelectedDocument(newDoc) setShowNewDocModal(false) setNewDocForm({ type: 'privacy_policy', name: '', description: '' }) } else { setError('Fehler beim Erstellen des Dokuments') } } catch { setError('Verbindungsfehler beim Erstellen') } finally { setCreatingDoc(false) } } const getNextVersionNumber = () => { if (versions.length === 0) return '1.0' const latest = versions[0] const parts = latest.version.split('.') const major = parseInt(parts[0]) || 1 const minor = parseInt(parts[1]) || 0 return `${major}.${minor + 1}` } const loadApprovalHistory = async (versionId: string) => { try { const res = await fetch(`/api/admin/consent/versions/${versionId}/approval-history`) if (res.ok) { const data = await res.json() setApprovalHistory(data.approval_history || []) } } catch { console.error('Failed to load approval history') } } const isEditable = draftVersion?.status === 'draft' || !draftVersion return (
{/* Error Banner */} {error && (
{error}
)} {/* Document Selector */}
{currentVersion && ( Aktuelle Version: v{currentVersion.version} )} {draftVersion && ( {STATUS_LABELS[draftVersion.status].label}: v{draftVersion.version} )}
{loading ? (
) : !selectedDocument ? (
Bitte waehlen Sie ein Dokument aus
) : ( <> {/* Workflow Status Bar */}
{['draft', 'review', 'approved', 'published'].map((status, idx) => (
{idx > 0 &&
}
{idx + 1}
{status === 'draft' ? 'Entwurf' : status === 'review' ? 'Pruefung' : status === 'approved' ? 'Freigegeben' : 'Veroeffentlicht'}
))}
{/* Action Buttons */}
{!draftVersion && ( )} {draftVersion?.status === 'draft' && ( <> )} {draftVersion?.status === 'review' && ( <> )} {draftVersion?.status === 'approved' && ( )} {draftVersion?.status === 'rejected' && (
Abgelehnt: {draftVersion.rejection_reason}
)}
{/* Rich Text Toolbar - only shown when editable */} {isEditable && (
{/* Formatting */}
{/* Headings */}
{/* Lists */}
{/* Links */}
{/* Word Upload */}
)} {uploadError && (
{uploadError}
)} {/* Split View Editor - Synchronized Scrolling */}
{/* Left: Current Published Version */}

Veroeffentlichte Version

{currentVersion && (

v{currentVersion.version}

)}
Nur Lesen
{currentVersion ? ( <>
) : (
Keine veroeffentlichte Version vorhanden
)}
{/* Right: Draft/Edit Version */}

{draftVersion ? 'Aenderungsversion' : 'Neue Version'}

{draftVersion && (

v{draftVersion.version} - {STATUS_LABELS[draftVersion.status].label}

)}
{isEditable && ( Bearbeitbar )}
setEditedTitle(e.target.value)} disabled={!isEditable} placeholder="Titel der Version..." className={`w-full px-3 py-2 mb-4 border rounded-lg ${ isEditable ? 'border-slate-300 bg-white' : 'border-slate-200 bg-slate-50 text-slate-700' }`} /> {isEditable ? (
) : (
)} {/* Character count */}
{(editorRef.current?.textContent || editedContent.replace(/<[^>]*>/g, '')).length} Zeichen
{/* History Panel */} {showHistory && (

Genehmigungsverlauf

{approvalHistory.length > 0 ? (
{approvalHistory.map((item, idx) => (
{item.action} {item.approver || 'System'} {item.comment && ( "{item.comment}" )} {new Date(item.created_at).toLocaleString('de-DE')}
))}
) : (

Keine Genehmigungshistorie vorhanden.

)}

Alle Versionen

{versions.map((v) => (
v{v.version} {STATUS_LABELS[v.status].label} {v.title}
{new Date(v.created_at).toLocaleDateString('de-DE')}
))}
)} )} {/* Full Screen Compare View */} {showCompareView && (
{/* Header */}

Versionsvergleich

{currentVersion ? `v${currentVersion.version}` : 'Keine Version'} vs {draftVersion ? `v${draftVersion.version}` : 'Neue Version'}
{/* Compare Panels */}
{/* Left: Published */}
Veroeffentlichte Version {currentVersion && ( v{currentVersion.version} )}
{currentVersion ? (
) : (

Keine veroeffentlichte Version

)}
{/* Right: Draft */}
{draftVersion ? 'Aenderungsversion' : 'Neue Version'} {draftVersion && ( v{draftVersion.version} - {STATUS_LABELS[draftVersion.status].label} )}
{/* Footer with Actions */}
{draftVersion?.status === 'draft' && ( <> )} {draftVersion?.status === 'review' && ( <> )} {draftVersion?.status === 'approved' && ( )}
)} {/* New Document Modal */} {showNewDocModal && (

Neues Dokument erstellen

setNewDocForm({ ...newDocForm, name: e.target.value })} placeholder="z.B. Datenschutzerklärung Website" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" />