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,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,
}
}