All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1176 lines
47 KiB
TypeScript
1176 lines
47 KiB
TypeScript
'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<string, { label: string; color: string }> = {
|
|
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<string, string> = {
|
|
terms: 'AGB',
|
|
privacy: 'Datenschutzerklaerung',
|
|
cookies: 'Cookie-Richtlinie',
|
|
community_guidelines: 'Community-Richtlinien',
|
|
imprint: 'Impressum',
|
|
}
|
|
|
|
export default function WorkflowPage() {
|
|
const { state } = useSDK()
|
|
const [documents, setDocuments] = useState<Document[]>([])
|
|
const [versions, setVersions] = useState<Version[]>([])
|
|
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null)
|
|
const [currentVersion, setCurrentVersion] = useState<Version | null>(null)
|
|
const [draftVersion, setDraftVersion] = useState<Version | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [editedContent, setEditedContent] = useState('')
|
|
const [editedTitle, setEditedTitle] = useState('')
|
|
const [editedSummary, setEditedSummary] = useState('')
|
|
const [showHistory, setShowHistory] = useState(false)
|
|
const [approvalHistory, setApprovalHistory] = useState<ApprovalHistoryItem[]>([])
|
|
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<string | null>(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<HTMLDivElement>(null)
|
|
const rightPanelRef = useRef<HTMLDivElement>(null)
|
|
const editorRef = useRef<HTMLDivElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
|
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 || '<p>Konvertierung fehlgeschlagen</p>'
|
|
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><\/o:p>/gi, '')
|
|
cleaned = cleaned.replace(/<\/?o:[^>]*>/gi, '')
|
|
cleaned = cleaned.replace(/<\/?w:[^>]*>/gi, '')
|
|
cleaned = cleaned.replace(/<\/?m:[^>]*>/gi, '')
|
|
cleaned = cleaned.replace(/<span[^>]*>\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 (
|
|
<div className="space-y-6">
|
|
<StepHeader stepId="workflow" showProgress={true} />
|
|
|
|
{/* Error Banner */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
|
|
<span className="text-red-700">{error}</span>
|
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Document Selector */}
|
|
<div className="bg-white rounded-xl shadow-sm border p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<label className="text-sm font-medium text-slate-700">Dokument:</label>
|
|
<select
|
|
value={selectedDocument?.id || ''}
|
|
onChange={(e) => {
|
|
const doc = documents.find(d => d.id === e.target.value)
|
|
setSelectedDocument(doc || null)
|
|
}}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg text-sm min-w-[250px]"
|
|
>
|
|
<option value="">Dokument auswaehlen...</option>
|
|
{documents.map((doc) => (
|
|
<option key={doc.id} value={doc.id}>
|
|
{DOCUMENT_TYPES[doc.type] || doc.type} - {doc.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{currentVersion && (
|
|
<span className="text-sm text-slate-500">
|
|
Aktuelle Version: <span className="font-medium">v{currentVersion.version}</span>
|
|
</span>
|
|
)}
|
|
{draftVersion && (
|
|
<span className={`px-2 py-1 rounded text-xs ${STATUS_LABELS[draftVersion.status].color}`}>
|
|
{STATUS_LABELS[draftVersion.status].label}: v{draftVersion.version}
|
|
</span>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setShowNewDocModal(true)}
|
|
className="px-3 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg"
|
|
>
|
|
+ Neues Dokument
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowCompareView(true)}
|
|
className="px-3 py-2 text-sm text-purple-600 hover:text-purple-800 border border-purple-300 rounded-lg hover:bg-purple-50"
|
|
title="Vollbild-Vergleich"
|
|
>
|
|
Vollbild
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setShowHistory(!showHistory)
|
|
if (draftVersion && !showHistory) {
|
|
loadApprovalHistory(draftVersion.id)
|
|
}
|
|
}}
|
|
className="px-3 py-2 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:bg-slate-50"
|
|
>
|
|
Historie
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : !selectedDocument ? (
|
|
<div className="bg-white rounded-xl shadow-sm border p-12 text-center text-slate-500">
|
|
Bitte waehlen Sie ein Dokument aus
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Workflow Status Bar */}
|
|
<div className="bg-white rounded-xl shadow-sm border p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-6">
|
|
{['draft', 'review', 'approved', 'published'].map((status, idx) => (
|
|
<div key={status} className="flex items-center">
|
|
{idx > 0 && <div className="w-8 h-0.5 bg-slate-200 mr-2" />}
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
|
(status === 'draft' && draftVersion?.status === 'draft') ||
|
|
(status === 'review' && draftVersion?.status === 'review') ||
|
|
(status === 'approved' && draftVersion?.status === 'approved') ||
|
|
(status === 'published' && !draftVersion)
|
|
? 'bg-purple-500 text-white'
|
|
: 'bg-slate-200 text-slate-600'
|
|
}`}>{idx + 1}</div>
|
|
<span className="text-sm text-slate-600">
|
|
{status === 'draft' ? 'Entwurf' :
|
|
status === 'review' ? 'Pruefung' :
|
|
status === 'approved' ? 'Freigegeben' : 'Veroeffentlicht'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center gap-2">
|
|
{!draftVersion && (
|
|
<button
|
|
onClick={createNewDraft}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
Neue Version erstellen
|
|
</button>
|
|
)}
|
|
|
|
{draftVersion?.status === 'draft' && (
|
|
<>
|
|
<button
|
|
onClick={saveDraft}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
{saving ? 'Speichern...' : 'Speichern'}
|
|
</button>
|
|
<button
|
|
onClick={submitForReview}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
Zur Pruefung einreichen
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{draftVersion?.status === 'review' && (
|
|
<>
|
|
<button
|
|
onClick={() => setShowApprovalModal('reject')}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
<button
|
|
onClick={() => setShowApprovalModal('approve')}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
Freigeben
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{draftVersion?.status === 'approved' && (
|
|
<button
|
|
onClick={publishVersion}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
Jetzt veroeffentlichen
|
|
</button>
|
|
)}
|
|
|
|
{draftVersion?.status === 'rejected' && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-red-600">Abgelehnt: {draftVersion.rejection_reason}</span>
|
|
<button
|
|
onClick={createNewDraft}
|
|
disabled={saving}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
Neu bearbeiten
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rich Text Toolbar - only shown when editable */}
|
|
{isEditable && (
|
|
<div className="bg-white rounded-xl shadow-sm border p-3">
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{/* Formatting */}
|
|
<div className="flex items-center gap-1 border-r border-slate-200 pr-2 mr-2">
|
|
<button onClick={() => formatDoc('bold')} className="p-2 hover:bg-slate-100 rounded" title="Fett">
|
|
<span className="font-bold">B</span>
|
|
</button>
|
|
<button onClick={() => formatDoc('italic')} className="p-2 hover:bg-slate-100 rounded" title="Kursiv">
|
|
<span className="italic">I</span>
|
|
</button>
|
|
<button onClick={() => formatDoc('underline')} className="p-2 hover:bg-slate-100 rounded" title="Unterstrichen">
|
|
<span className="underline">U</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Headings */}
|
|
<div className="flex items-center gap-1 border-r border-slate-200 pr-2 mr-2">
|
|
<button onClick={() => formatBlock('h1')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Ueberschrift 1">
|
|
H1
|
|
</button>
|
|
<button onClick={() => formatBlock('h2')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Ueberschrift 2">
|
|
H2
|
|
</button>
|
|
<button onClick={() => formatBlock('h3')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Ueberschrift 3">
|
|
H3
|
|
</button>
|
|
<button onClick={() => formatBlock('p')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Absatz">
|
|
P
|
|
</button>
|
|
</div>
|
|
|
|
{/* Lists */}
|
|
<div className="flex items-center gap-1 border-r border-slate-200 pr-2 mr-2">
|
|
<button onClick={() => formatDoc('insertUnorderedList')} className="p-2 hover:bg-slate-100 rounded" title="Aufzaehlung">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
</button>
|
|
<button onClick={() => formatDoc('insertOrderedList')} className="p-2 hover:bg-slate-100 rounded" title="Nummerierung">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Links */}
|
|
<div className="flex items-center gap-1 border-r border-slate-200 pr-2 mr-2">
|
|
<button onClick={insertLink} className="p-2 hover:bg-slate-100 rounded" title="Link einfuegen">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Word Upload */}
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleWordUpload}
|
|
accept=".docx,.doc"
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
className="px-3 py-2 bg-blue-50 text-blue-700 hover:bg-blue-100 rounded text-sm flex items-center gap-1"
|
|
title="Word-Dokument importieren"
|
|
>
|
|
<svg className="w-4 h-4" 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-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
{uploading ? 'Importiere...' : 'Word importieren'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{uploadError && (
|
|
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700 flex items-center justify-between">
|
|
<span>{uploadError}</span>
|
|
<button onClick={() => setUploadError(null)} className="ml-4 text-red-500 hover:text-red-700">✕</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Split View Editor - Synchronized Scrolling */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Left: Current Published Version */}
|
|
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
|
<div className="bg-emerald-50 border-b border-emerald-200 px-4 py-3 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-semibold text-emerald-900">Veroeffentlichte Version</h3>
|
|
{currentVersion && (
|
|
<p className="text-sm text-emerald-700">v{currentVersion.version}</p>
|
|
)}
|
|
</div>
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">
|
|
Nur Lesen
|
|
</span>
|
|
</div>
|
|
<div className="p-4">
|
|
{currentVersion ? (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={currentVersion.title}
|
|
disabled
|
|
className="w-full px-3 py-2 mb-4 bg-slate-50 border border-slate-200 rounded-lg text-slate-700"
|
|
/>
|
|
<div
|
|
ref={leftPanelRef}
|
|
className="prose prose-sm max-w-none p-4 bg-slate-50 border border-slate-200 rounded-lg min-h-[500px] max-h-[500px] overflow-y-auto"
|
|
dangerouslySetInnerHTML={{ __html: currentVersion.content }}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-12 text-slate-500">
|
|
Keine veroeffentlichte Version vorhanden
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Draft/Edit Version */}
|
|
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
|
<div className={`border-b px-4 py-3 flex items-center justify-between ${
|
|
draftVersion?.status === 'draft' ? 'bg-yellow-50 border-yellow-200' :
|
|
draftVersion?.status === 'review' ? 'bg-blue-50 border-blue-200' :
|
|
draftVersion?.status === 'approved' ? 'bg-green-50 border-green-200' :
|
|
'bg-slate-50 border-slate-200'
|
|
}`}>
|
|
<div>
|
|
<h3 className={`font-semibold ${
|
|
draftVersion?.status === 'draft' ? 'text-yellow-900' :
|
|
draftVersion?.status === 'review' ? 'text-blue-900' :
|
|
draftVersion?.status === 'approved' ? 'text-green-900' :
|
|
'text-slate-900'
|
|
}`}>
|
|
{draftVersion ? 'Aenderungsversion' : 'Neue Version'}
|
|
</h3>
|
|
{draftVersion && (
|
|
<p className={`text-sm ${
|
|
draftVersion.status === 'draft' ? 'text-yellow-700' :
|
|
draftVersion.status === 'review' ? 'text-blue-700' :
|
|
draftVersion.status === 'approved' ? 'text-green-700' :
|
|
'text-slate-700'
|
|
}`}>
|
|
v{draftVersion.version} - {STATUS_LABELS[draftVersion.status].label}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{isEditable && (
|
|
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">
|
|
Bearbeitbar
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="p-4">
|
|
<input
|
|
type="text"
|
|
value={editedTitle}
|
|
onChange={(e) => 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 ? (
|
|
<div
|
|
ref={rightPanelRef}
|
|
className="min-h-[500px] max-h-[500px] overflow-y-auto"
|
|
>
|
|
<div
|
|
ref={editorRef}
|
|
contentEditable
|
|
onInput={updateEditorContent}
|
|
onPaste={handlePaste}
|
|
className="prose prose-sm max-w-none p-4 bg-white border border-slate-300 rounded-lg min-h-[500px] focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
dangerouslySetInnerHTML={{ __html: editedContent }}
|
|
suppressContentEditableWarning
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={rightPanelRef}
|
|
className="prose prose-sm max-w-none p-4 bg-slate-50 border border-slate-200 rounded-lg min-h-[500px] max-h-[500px] overflow-y-auto"
|
|
dangerouslySetInnerHTML={{ __html: editedContent || draftVersion?.content || '' }}
|
|
/>
|
|
)}
|
|
|
|
{/* Character count */}
|
|
<div className="mt-2 text-right text-sm text-slate-500">
|
|
{(editorRef.current?.textContent || editedContent.replace(/<[^>]*>/g, '')).length} Zeichen
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* History Panel */}
|
|
{showHistory && (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Genehmigungsverlauf</h3>
|
|
{approvalHistory.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{approvalHistory.map((item, idx) => (
|
|
<div key={idx} className="flex items-center gap-4 p-3 border border-slate-200 rounded-lg">
|
|
<span className={`px-2 py-1 rounded text-xs ${
|
|
item.action === 'approved' ? 'bg-green-100 text-green-700' :
|
|
item.action === 'rejected' ? 'bg-red-100 text-red-700' :
|
|
item.action === 'submitted' ? 'bg-blue-100 text-blue-700' :
|
|
'bg-slate-100 text-slate-700'
|
|
}`}>{item.action}</span>
|
|
<span className="text-sm text-slate-600">{item.approver || 'System'}</span>
|
|
{item.comment && (
|
|
<span className="text-sm text-slate-500 italic">"{item.comment}"</span>
|
|
)}
|
|
<span className="text-sm text-slate-400 ml-auto">
|
|
{new Date(item.created_at).toLocaleString('de-DE')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-slate-500">Keine Genehmigungshistorie vorhanden.</p>
|
|
)}
|
|
|
|
<h3 className="text-lg font-semibold text-slate-900 mt-6 mb-4">Alle Versionen</h3>
|
|
<div className="space-y-3">
|
|
{versions.map((v) => (
|
|
<div
|
|
key={v.id}
|
|
className={`p-4 border rounded-lg ${
|
|
v.id === draftVersion?.id ? 'border-purple-300 bg-purple-50' :
|
|
v.id === currentVersion?.id ? 'border-emerald-300 bg-emerald-50' :
|
|
'border-slate-200'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono font-medium">v{v.version}</span>
|
|
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_LABELS[v.status].color}`}>
|
|
{STATUS_LABELS[v.status].label}
|
|
</span>
|
|
<span className="text-sm text-slate-500">{v.title}</span>
|
|
</div>
|
|
<span className="text-sm text-slate-400">
|
|
{new Date(v.created_at).toLocaleDateString('de-DE')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Full Screen Compare View */}
|
|
{showCompareView && (
|
|
<div className="fixed inset-0 bg-slate-900 z-50 flex flex-col">
|
|
{/* Header */}
|
|
<div className="bg-slate-800 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h2 className="text-xl font-semibold text-white">Versionsvergleich</h2>
|
|
<span className="text-slate-400">
|
|
{currentVersion ? `v${currentVersion.version}` : 'Keine Version'}
|
|
<span className="mx-2 text-slate-600">vs</span>
|
|
{draftVersion ? `v${draftVersion.version}` : 'Neue Version'}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCompareView(false)}
|
|
className="px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600"
|
|
>
|
|
Schliessen
|
|
</button>
|
|
</div>
|
|
|
|
{/* Compare Panels */}
|
|
<div className="flex-1 grid grid-cols-2 gap-1 bg-slate-700">
|
|
{/* Left: Published */}
|
|
<div className="bg-white flex flex-col">
|
|
<div className="bg-emerald-100 border-b border-emerald-200 px-4 py-2">
|
|
<span className="font-medium text-emerald-800">Veroeffentlichte Version</span>
|
|
{currentVersion && (
|
|
<span className="ml-2 text-emerald-600">v{currentVersion.version}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 prose prose-sm max-w-none">
|
|
{currentVersion ? (
|
|
<div dangerouslySetInnerHTML={{ __html: currentVersion.content }} />
|
|
) : (
|
|
<p className="text-slate-500 text-center py-12">Keine veroeffentlichte Version</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Draft */}
|
|
<div className="bg-white flex flex-col">
|
|
<div className={`border-b px-4 py-2 ${
|
|
draftVersion?.status === 'draft' ? 'bg-yellow-100 border-yellow-200' :
|
|
draftVersion?.status === 'review' ? 'bg-blue-100 border-blue-200' :
|
|
draftVersion?.status === 'approved' ? 'bg-green-100 border-green-200' :
|
|
'bg-slate-100 border-slate-200'
|
|
}`}>
|
|
<span className={`font-medium ${
|
|
draftVersion?.status === 'draft' ? 'text-yellow-800' :
|
|
draftVersion?.status === 'review' ? 'text-blue-800' :
|
|
draftVersion?.status === 'approved' ? 'text-green-800' :
|
|
'text-slate-800'
|
|
}`}>
|
|
{draftVersion ? 'Aenderungsversion' : 'Neue Version'}
|
|
</span>
|
|
{draftVersion && (
|
|
<span className={`ml-2 ${
|
|
draftVersion.status === 'draft' ? 'text-yellow-600' :
|
|
draftVersion.status === 'review' ? 'text-blue-600' :
|
|
draftVersion.status === 'approved' ? 'text-green-600' :
|
|
'text-slate-600'
|
|
}`}>
|
|
v{draftVersion.version} - {STATUS_LABELS[draftVersion.status].label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 prose prose-sm max-w-none">
|
|
<div dangerouslySetInnerHTML={{ __html: editedContent || draftVersion?.content || '' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer with Actions */}
|
|
<div className="bg-slate-800 border-t border-slate-700 px-6 py-4 flex items-center justify-end gap-3">
|
|
{draftVersion?.status === 'draft' && (
|
|
<>
|
|
<button
|
|
onClick={() => { setShowCompareView(false); saveDraft() }}
|
|
className="px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-500"
|
|
>
|
|
Speichern
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowCompareView(false); submitForReview() }}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500"
|
|
>
|
|
Zur Pruefung einreichen
|
|
</button>
|
|
</>
|
|
)}
|
|
{draftVersion?.status === 'review' && (
|
|
<>
|
|
<button
|
|
onClick={() => { setShowCompareView(false); setShowApprovalModal('reject') }}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500"
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowCompareView(false); setShowApprovalModal('approve') }}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-500"
|
|
>
|
|
Freigeben
|
|
</button>
|
|
</>
|
|
)}
|
|
{draftVersion?.status === 'approved' && (
|
|
<button
|
|
onClick={() => { setShowCompareView(false); publishVersion() }}
|
|
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500"
|
|
>
|
|
Veroeffentlichen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* New Document Modal */}
|
|
{showNewDocModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<h2 className="text-lg font-bold text-gray-900">Neues Dokument erstellen</h2>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumenttyp</label>
|
|
<select
|
|
value={newDocForm.type}
|
|
onChange={(e) => setNewDocForm({ ...newDocForm, type: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
>
|
|
<option value="privacy_policy">Datenschutzerklärung</option>
|
|
<option value="terms">AGB</option>
|
|
<option value="cookie_policy">Cookie-Richtlinie</option>
|
|
<option value="imprint">Impressum</option>
|
|
<option value="dpa">AVV (Auftragsverarbeitung)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
value={newDocForm.name}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung (optional)</label>
|
|
<textarea
|
|
rows={2}
|
|
value={newDocForm.description}
|
|
onChange={(e) => setNewDocForm({ ...newDocForm, description: e.target.value })}
|
|
placeholder="Kurze Beschreibung..."
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowNewDocModal(false)}
|
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={createDocument}
|
|
disabled={creatingDoc || !newDocForm.name.trim()}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
{creatingDoc ? 'Erstellen...' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Approval Modal */}
|
|
{showApprovalModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-md">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">
|
|
{showApprovalModal === 'approve' ? 'Version freigeben' : 'Version ablehnen'}
|
|
</h3>
|
|
<textarea
|
|
value={approvalComment}
|
|
onChange={(e) => setApprovalComment(e.target.value)}
|
|
placeholder={showApprovalModal === 'approve' ? 'Kommentar (optional)...' : 'Ablehnungsgrund...'}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg min-h-[100px] mb-4"
|
|
required={showApprovalModal === 'reject'}
|
|
/>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => {
|
|
setShowApprovalModal(null)
|
|
setApprovalComment('')
|
|
}}
|
|
className="px-4 py-2 text-slate-600 hover:text-slate-900"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={showApprovalModal === 'approve' ? approveVersion : rejectVersion}
|
|
disabled={saving || (showApprovalModal === 'reject' && !approvalComment)}
|
|
className={`px-4 py-2 text-white rounded-lg disabled:opacity-50 ${
|
|
showApprovalModal === 'approve'
|
|
? 'bg-green-600 hover:bg-green-700'
|
|
: 'bg-red-600 hover:bg-red-700'
|
|
}`}
|
|
>
|
|
{saving ? 'Wird verarbeitet...' : showApprovalModal === 'approve' ? 'Freigeben' : 'Ablehnen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|