refactor(admin): split workflow page.tsx into colocated components

Split 1175-LOC workflow page into _components, _hooks and _types modules.
page.tsx now 256 LOC (wire-up only). Behavior preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-14 22:50:29 +02:00
parent 637eb012f5
commit 82a5a388b8
13 changed files with 1414 additions and 1034 deletions

View File

@@ -0,0 +1,55 @@
'use client'
interface ApprovalModalProps {
mode: 'approve' | 'reject'
approvalComment: string
onCommentChange: (comment: string) => void
onCancel: () => void
onConfirm: () => void
saving: boolean
}
export default function ApprovalModal({
mode,
approvalComment,
onCommentChange,
onCancel,
onConfirm,
saving,
}: ApprovalModalProps) {
return (
<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">
{mode === 'approve' ? 'Version freigeben' : 'Version ablehnen'}
</h3>
<textarea
value={approvalComment}
onChange={(e) => onCommentChange(e.target.value)}
placeholder={mode === 'approve' ? 'Kommentar (optional)...' : 'Ablehnungsgrund...'}
className="w-full px-3 py-2 border border-slate-300 rounded-lg min-h-[100px] mb-4"
required={mode === 'reject'}
/>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-slate-600 hover:text-slate-900"
>
Abbrechen
</button>
<button
onClick={onConfirm}
disabled={saving || (mode === 'reject' && !approvalComment)}
className={`px-4 py-2 text-white rounded-lg disabled:opacity-50 ${
mode === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
}`}
>
{saving ? 'Wird verarbeitet...' : mode === 'approve' ? 'Freigeben' : 'Ablehnen'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import { Version, STATUS_LABELS } from '../_types'
interface CompareViewProps {
currentVersion: Version | null
draftVersion: Version | null
editedContent: string
onClose: () => void
onSaveDraft: () => void
onSubmitForReview: () => void
onShowApprovalModal: (mode: 'approve' | 'reject') => void
onPublishVersion: () => void
}
export default function CompareView({
currentVersion,
draftVersion,
editedContent,
onClose,
onSaveDraft,
onSubmitForReview,
onShowApprovalModal,
onPublishVersion,
}: CompareViewProps) {
return (
<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={onClose}
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={() => { onClose(); onSaveDraft() }}
className="px-4 py-2 bg-slate-600 text-white rounded-lg hover:bg-slate-500"
>
Speichern
</button>
<button
onClick={() => { onClose(); onSubmitForReview() }}
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={() => { onClose(); onShowApprovalModal('reject') }}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500"
>
Ablehnen
</button>
<button
onClick={() => { onClose(); onShowApprovalModal('approve') }}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-500"
>
Freigeben
</button>
</>
)}
{draftVersion?.status === 'approved' && (
<button
onClick={() => { onClose(); onPublishVersion() }}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500"
>
Veroeffentlichen
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { Document, Version, STATUS_LABELS, DOCUMENT_TYPES } from '../_types'
interface DocumentSelectorBarProps {
documents: Document[]
selectedDocument: Document | null
onSelectDocument: (doc: Document | null) => void
currentVersion: Version | null
draftVersion: Version | null
onNewDocument: () => void
onCompareView: () => void
onToggleHistory: () => void
}
export default function DocumentSelectorBar({
documents,
selectedDocument,
onSelectDocument,
currentVersion,
draftVersion,
onNewDocument,
onCompareView,
onToggleHistory,
}: DocumentSelectorBarProps) {
return (
<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)
onSelectDocument(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={onNewDocument}
className="px-3 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg"
>
+ Neues Dokument
</button>
<button
onClick={onCompareView}
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={onToggleHistory}
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>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { Version, ApprovalHistoryItem, STATUS_LABELS } from '../_types'
interface HistoryPanelProps {
approvalHistory: ApprovalHistoryItem[]
versions: Version[]
draftVersion: Version | null
currentVersion: Version | null
}
export default function HistoryPanel({
approvalHistory,
versions,
draftVersion,
currentVersion,
}: HistoryPanelProps) {
return (
<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">&quot;{item.comment}&quot;</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>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
interface NewDocForm {
type: string
name: string
description: string
}
interface NewDocumentModalProps {
newDocForm: NewDocForm
onChange: (form: NewDocForm) => void
onClose: () => void
onCreate: () => void
creatingDoc: boolean
}
export default function NewDocumentModal({
newDocForm,
onChange,
onClose,
onCreate,
creatingDoc,
}: NewDocumentModalProps) {
return (
<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) => onChange({ ...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) => onChange({ ...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) => onChange({ ...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={onClose}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
Abbrechen
</button>
<button
onClick={onCreate}
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>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import { RefObject } from 'react'
interface RichTextToolbarProps {
fileInputRef: RefObject<HTMLInputElement | null>
uploading: boolean
onFormatDoc: (cmd: string, value?: string | null) => void
onFormatBlock: (tag: string) => void
onInsertLink: () => void
onWordUpload: (event: React.ChangeEvent<HTMLInputElement>) => void
}
export default function RichTextToolbar({
fileInputRef,
uploading,
onFormatDoc,
onFormatBlock,
onInsertLink,
onWordUpload,
}: RichTextToolbarProps) {
return (
<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={() => onFormatDoc('bold')} className="p-2 hover:bg-slate-100 rounded" title="Fett">
<span className="font-bold">B</span>
</button>
<button onClick={() => onFormatDoc('italic')} className="p-2 hover:bg-slate-100 rounded" title="Kursiv">
<span className="italic">I</span>
</button>
<button onClick={() => onFormatDoc('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={() => onFormatBlock('h1')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Ueberschrift 1">
H1
</button>
<button onClick={() => onFormatBlock('h2')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Ueberschrift 2">
H2
</button>
<button onClick={() => onFormatBlock('h3')} className="p-2 hover:bg-slate-100 rounded text-sm" title="Ueberschrift 3">
H3
</button>
<button onClick={() => onFormatBlock('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={() => onFormatDoc('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={() => onFormatDoc('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={onInsertLink} 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={onWordUpload}
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>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { RefObject } from 'react'
import { Version, STATUS_LABELS } from '../_types'
interface SplitViewEditorProps {
leftPanelRef: RefObject<HTMLDivElement | null>
rightPanelRef: RefObject<HTMLDivElement | null>
editorRef: RefObject<HTMLDivElement | null>
currentVersion: Version | null
draftVersion: Version | null
isEditable: boolean
editedTitle: string
editedContent: string
onTitleChange: (title: string) => void
onUpdateEditorContent: () => void
onPaste: (e: React.ClipboardEvent) => void
}
export default function SplitViewEditor({
leftPanelRef,
rightPanelRef,
editorRef,
currentVersion,
draftVersion,
isEditable,
editedTitle,
editedContent,
onTitleChange,
onUpdateEditorContent,
onPaste,
}: SplitViewEditorProps) {
return (
<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) => onTitleChange(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={onUpdateEditorContent}
onPaste={onPaste}
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>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { Version } from '../_types'
interface WorkflowStatusBarProps {
draftVersion: Version | null
saving: boolean
onCreateNewDraft: () => void
onSaveDraft: () => void
onSubmitForReview: () => void
onShowApprovalModal: (mode: 'approve' | 'reject') => void
onPublishVersion: () => void
}
export default function WorkflowStatusBar({
draftVersion,
saving,
onCreateNewDraft,
onSaveDraft,
onSubmitForReview,
onShowApprovalModal,
onPublishVersion,
}: WorkflowStatusBarProps) {
return (
<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={onCreateNewDraft}
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={onSaveDraft}
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={onSubmitForReview}
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={() => onShowApprovalModal('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={() => onShowApprovalModal('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={onPublishVersion}
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={onCreateNewDraft}
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>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import { useState, useRef, RefObject } from 'react'
interface UseRichTextEditorResult {
editorRef: RefObject<HTMLDivElement | null>
fileInputRef: RefObject<HTMLInputElement | null>
uploading: boolean
uploadError: string | null
setUploadError: (e: string | null) => void
formatDoc: (cmd: string, value?: string | null) => void
formatBlock: (tag: string) => void
insertLink: () => void
updateEditorContent: () => void
handleWordUpload: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>
handlePaste: (e: React.ClipboardEvent) => void
}
export function useRichTextEditor(
setEditedContent: (content: string) => void,
): UseRichTextEditorResult {
const editorRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const updateEditorContent = () => {
if (editorRef.current) {
setEditedContent(editorRef.current.innerHTML)
}
}
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 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 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 = ''
}
}
}
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()
}
}
return {
editorRef,
fileInputRef,
uploading,
uploadError,
setUploadError,
formatDoc,
formatBlock,
insertLink,
updateEditorContent,
handleWordUpload,
handlePaste,
}
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useCallback, useEffect, useRef, RefObject } from 'react'
export function useSyncScroll(
leftPanelRef: RefObject<HTMLDivElement | null>,
rightPanelRef: RefObject<HTMLDivElement | null>,
deps: unknown[],
) {
const isScrolling = useRef(false)
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)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const cleanup = setupSyncScroll()
return cleanup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setupSyncScroll, ...deps])
}

View File

@@ -0,0 +1,248 @@
'use client'
import { useState } from 'react'
import { Document, Version, ApprovalHistoryItem } from '../_types'
interface UseWorkflowActionsParams {
selectedDocument: Document | null
setDocuments: React.Dispatch<React.SetStateAction<Document[]>>
setSelectedDocument: React.Dispatch<React.SetStateAction<Document | null>>
draftVersion: Version | null
currentVersion: Version | null
versions: Version[]
editedTitle: string
editedContent: string
editedSummary: string
loadVersions: (docId: string) => Promise<void>
setError: (msg: string | null) => void
}
export function useWorkflowActions(params: UseWorkflowActionsParams) {
const {
selectedDocument, setDocuments, setSelectedDocument,
draftVersion, currentVersion, versions,
editedTitle, editedContent, editedSummary,
loadVersions, setError,
} = params
const [saving, setSaving] = useState(false)
const [approvalComment, setApprovalComment] = useState('')
const [showApprovalModal, setShowApprovalModal] = useState<'approve' | 'reject' | null>(null)
const [approvalHistory, setApprovalHistory] = useState<ApprovalHistoryItem[]>([])
const [showNewDocModal, setShowNewDocModal] = useState(false)
const [newDocForm, setNewDocForm] = useState({ type: 'privacy_policy', name: '', description: '' })
const [creatingDoc, setCreatingDoc] = useState(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 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 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')
}
}
return {
saving,
approvalComment, setApprovalComment,
showApprovalModal, setShowApprovalModal,
approvalHistory,
showNewDocModal, setShowNewDocModal,
newDocForm, setNewDocForm,
creatingDoc,
createNewDraft, saveDraft, submitForReview,
approveVersion, rejectVersion, publishVersion,
createDocument, loadApprovalHistory,
}
}

View File

@@ -0,0 +1,50 @@
export interface Document {
id: string
type: string
name: string
description: string
mandatory: boolean
created_at: string
updated_at: string
}
export 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
}
export interface ApprovalHistoryItem {
action: string
approver: string
comment: string
created_at: string
}
export 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' },
}
export const DOCUMENT_TYPES: Record<string, string> = {
terms: 'AGB',
privacy: 'Datenschutzerklaerung',
cookies: 'Cookie-Richtlinie',
community_guidelines: 'Community-Richtlinien',
imprint: 'Impressum',
}

File diff suppressed because it is too large Load Diff