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:
131
admin-compliance/app/sdk/workflow/_hooks/useRichTextEditor.ts
Normal file
131
admin-compliance/app/sdk/workflow/_hooks/useRichTextEditor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
55
admin-compliance/app/sdk/workflow/_hooks/useSyncScroll.ts
Normal file
55
admin-compliance/app/sdk/workflow/_hooks/useSyncScroll.ts
Normal 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])
|
||||
}
|
||||
248
admin-compliance/app/sdk/workflow/_hooks/useWorkflowActions.ts
Normal file
248
admin-compliance/app/sdk/workflow/_hooks/useWorkflowActions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user