Files
breakpilot-compliance/admin-compliance/app/(sdk)/sdk/document-generator/page.tsx
Benjamin Admin 7e5047290c
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 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s
feat: Dokumentengenerator — Vollständige Vorlage-Bibliothek + Frontend-Redesign
- Migration 020: Typ-Renames (data_processing_agreement→dpa, withdrawal_policy→widerruf) + 11 neue MIT-Templates (NDA DE/EN, SLA, AUP, Community Guidelines, Copyright Policy, Cloud Service Agreement, Data Usage Clause, Cookie Banner, AGB, Liability Clause)
- Backend: VALID_DOCUMENT_TYPES auf 16 Typen erweitert; /legal-templates/status nutzt jetzt dynamisches GROUP BY statt Hard-coded Felder
- searchTemplates.ts: loadAllTemplates() für Library-First UX
- page.tsx: Vollständig-Rewrite — Template-Bibliothek (immer sichtbar) mit Kategorie-Pills, Sprache-Toggle, optionaler Suche, Inline-Preview-Expand und Kachel-Grid; Generator-Section erscheint per Scroll wenn Vorlage gewählt
- Tests: 52/52 bestanden, TestLegalTemplateNewTypes (19 neue Tests) + aktualisierte Typ-Checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 10:47:38 +01:00

592 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { useEinwilligungen, EinwilligungenProvider } from '@/lib/sdk/einwilligungen/context'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
LegalTemplateResult,
TemplateType,
LicenseType,
TEMPLATE_TYPE_LABELS,
LICENSE_TYPE_LABELS,
} from '@/lib/sdk/types'
import { DataPointsPreview } from './components/DataPointsPreview'
import { DocumentValidation } from './components/DocumentValidation'
import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-helpers'
import { loadAllTemplates, searchTemplates } from './searchTemplates'
// =============================================================================
// CATEGORY CONFIG
// =============================================================================
const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
{ key: 'all', label: 'Alle', types: null },
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
{ key: 'nda', label: 'NDA', types: ['nda'] },
{ key: 'sla', label: 'SLA', types: ['sla'] },
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
]
// =============================================================================
// SMALL COMPONENTS
// =============================================================================
function LicenseBadge({ licenseId, small = false }: { licenseId: LicenseType | null; small?: boolean }) {
if (!licenseId) return null
const colors: Partial<Record<LicenseType, string>> = {
public_domain: 'bg-green-100 text-green-700 border-green-200',
cc0: 'bg-green-100 text-green-700 border-green-200',
unlicense: 'bg-green-100 text-green-700 border-green-200',
mit: 'bg-blue-100 text-blue-700 border-blue-200',
cc_by_4: 'bg-purple-100 text-purple-700 border-purple-200',
reuse_notice: 'bg-orange-100 text-orange-700 border-orange-200',
}
return (
<span className={`${small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'} rounded border ${colors[licenseId] || 'bg-gray-100 text-gray-600 border-gray-200'}`}>
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
</span>
)
}
// =============================================================================
// LIBRARY CARD
// =============================================================================
function LibraryCard({
template,
expanded,
onTogglePreview,
onUse,
}: {
template: LegalTemplateResult
expanded: boolean
onTogglePreview: () => void
onUse: () => void
}) {
const typeLabel = template.templateType
? (TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType)
: null
const placeholderCount = template.placeholders?.length ?? 0
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-purple-300 transition-colors">
{/* Card header */}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-medium text-gray-900 text-sm leading-snug">
{template.documentTitle || 'Vorlage'}
</h3>
<span className="text-xs text-gray-400 uppercase shrink-0">{template.language}</span>
</div>
<div className="flex items-center gap-2 flex-wrap mb-3">
{typeLabel && (
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded">
{typeLabel}
</span>
)}
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
{placeholderCount > 0 && (
<span className="text-xs text-gray-500">
{placeholderCount} Platzh.
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onTogglePreview}
className="flex-1 text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
>
{expanded ? 'Vorschau ▲' : 'Vorschau ▼'}
</button>
<button
onClick={onUse}
className="flex-1 text-xs px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Verwenden
</button>
</div>
</div>
{/* Inline preview */}
{expanded && (
<div className="border-t border-gray-100 bg-gray-50 p-4 max-h-64 overflow-y-auto">
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono leading-relaxed">
{template.text.slice(0, 1500)}{template.text.length > 1500 ? '\n…' : ''}
</pre>
</div>
)}
</div>
)
}
// =============================================================================
// GENERATOR SECTION
// =============================================================================
function GeneratorSection({
template,
placeholderValues,
onPlaceholderChange,
onClose,
}: {
template: LegalTemplateResult
placeholderValues: Record<string, string>
onPlaceholderChange: (key: string, value: string) => void
onClose: () => void
}) {
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
const renderedContent = useMemo(() => {
let content = template.text
for (const [key, value] of Object.entries(placeholderValues)) {
if (value) {
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
}
}
return content
}, [template.text, placeholderValues])
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
const handleExportMarkdown = () => {
const blob = new Blob([renderedContent], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(template.documentTitle || 'dokument').replace(/\s+/g, '-').toLowerCase()}.md`
a.click()
URL.revokeObjectURL(url)
}
const handlePrint = () => window.print()
const placeholders = template.placeholders || []
return (
<div className="bg-white rounded-xl border-2 border-purple-300 overflow-hidden">
{/* Generator header */}
<div className="bg-purple-50 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<div>
<div className="text-xs text-purple-500 font-medium uppercase tracking-wide">Generator</div>
<div className="font-semibold text-gray-900 text-sm">{template.documentTitle}</div>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors" aria-label="Schließen">
<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>
{/* Tab bar */}
<div className="flex border-b border-gray-200 px-6">
{(['placeholders', 'preview'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab
? 'text-purple-600 border-purple-600'
: 'text-gray-500 border-transparent hover:text-gray-700'
}`}
>
{tab === 'placeholders' ? 'Platzhalter ausfüllen' : 'Vorschau & Export'}
</button>
))}
</div>
{/* Tab content */}
<div className="p-6">
{activeTab === 'placeholders' && (
<div className="space-y-4">
{placeholders.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
Keine Platzhalter Vorlage kann direkt verwendet werden.
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{placeholders.map((placeholder) => (
<div key={placeholder}>
<label className="block text-xs text-gray-600 mb-1 font-mono">{placeholder}</label>
<input
type="text"
value={placeholderValues[placeholder] || ''}
onChange={(e) => onPlaceholderChange(placeholder, e.target.value)}
placeholder={`Wert für ${placeholder}`}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
/>
</div>
))}
</div>
<div className="flex justify-end pt-2">
<button
onClick={() => setActiveTab('preview')}
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
>
Zur Vorschau
</button>
</div>
</>
)}
</div>
)}
{activeTab === 'preview' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{placeholders.length > 0 && Object.values(placeholderValues).filter(Boolean).length < placeholders.length && (
<span className="text-orange-600">
{placeholders.length - Object.values(placeholderValues).filter(Boolean).length} Platzhalter noch nicht ausgefüllt
</span>
)}
</span>
<div className="flex gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Kopieren
</button>
<button
onClick={handleExportMarkdown}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
>
<svg className="w-3.5 h-3.5" 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-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Markdown
</button>
<button
onClick={handlePrint}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF drucken
</button>
</div>
</div>
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
{renderedContent}
</pre>
</div>
{template.attributionRequired && template.attributionText && (
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
<strong>Attribution erforderlich:</strong> {template.attributionText}
</div>
)}
</div>
)}
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
function DocumentGeneratorPageInner() {
const { state } = useSDK()
const { selectedDataPointsData } = useEinwilligungen()
// Library state
const [allTemplates, setAllTemplates] = useState<LegalTemplateResult[]>([])
const [isLoadingLibrary, setIsLoadingLibrary] = useState(true)
const [activeCategory, setActiveCategory] = useState<string>('all')
const [activeLanguage, setActiveLanguage] = useState<'all' | 'de' | 'en'>('all')
const [librarySearch, setLibrarySearch] = useState('')
const [expandedPreviewId, setExpandedPreviewId] = useState<string | null>(null)
// Generator state
const [activeTemplate, setActiveTemplate] = useState<LegalTemplateResult | null>(null)
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>({})
const generatorRef = useRef<HTMLDivElement>(null)
// Status
const [totalCount, setTotalCount] = useState<number>(0)
// Load all templates on mount
useEffect(() => {
setIsLoadingLibrary(true)
loadAllTemplates(200).then((templates) => {
setAllTemplates(templates)
setTotalCount(templates.length)
setIsLoadingLibrary(false)
})
}, [])
// Pre-fill placeholders from company profile
useEffect(() => {
if (state?.companyProfile) {
const profile = state.companyProfile
setPlaceholderValues((prev) => ({
...prev,
'{{COMPANY_NAME}}': profile.companyName || '',
'{{FIRMENNAME}}': profile.companyName || '',
'{{CONTACT_EMAIL}}': profile.dpoEmail || '',
'{{DSB_EMAIL}}': profile.dpoEmail || '',
'{{DPO_NAME}}': profile.dpoName || '',
'{{DSB_NAME}}': profile.dpoName || '',
}))
}
}, [state?.companyProfile])
// Pre-fill from Einwilligungen data points
useEffect(() => {
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
const generated = generateAllPlaceholders(selectedDataPointsData, 'de')
setPlaceholderValues((prev) => ({ ...prev, ...generated }))
}
}, [selectedDataPointsData])
// Filtered templates (computed)
const filteredTemplates = useMemo(() => {
const category = CATEGORIES.find((c) => c.key === activeCategory)
return allTemplates.filter((t) => {
if (category && category.types !== null) {
if (!category.types.includes(t.templateType || '')) return false
}
if (activeLanguage !== 'all' && t.language !== activeLanguage) return false
if (librarySearch.trim()) {
const q = librarySearch.toLowerCase()
const title = (t.documentTitle || '').toLowerCase()
const type = (t.templateType || '').toLowerCase()
if (!title.includes(q) && !type.includes(q)) return false
}
return true
})
}, [allTemplates, activeCategory, activeLanguage, librarySearch])
const handleUseTemplate = useCallback((t: LegalTemplateResult) => {
setActiveTemplate(t)
setExpandedPreviewId(null)
// Scroll to generator
setTimeout(() => {
generatorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 100)
}, [])
const handleInsertPlaceholder = useCallback((placeholder: string) => {
if (!placeholderValues[placeholder]) {
const generated = generateAllPlaceholders(selectedDataPointsData || [], 'de')
if (generated[placeholder as keyof typeof generated]) {
setPlaceholderValues((prev) => ({
...prev,
[placeholder]: generated[placeholder as keyof typeof generated],
}))
}
}
}, [placeholderValues, selectedDataPointsData])
const stepInfo = STEP_EXPLANATIONS['document-generator'] || {
title: 'Dokumentengenerator',
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
explanation: 'Der Dokumentengenerator nutzt frei lizenzierte Textbausteine um Datenschutzerklaerungen, AGB und andere rechtliche Dokumente zu erstellen.',
tips: ['Wählen Sie eine Vorlage aus der Bibliothek aus', 'Füllen Sie die Platzhalter mit Ihren Unternehmensdaten'],
}
return (
<div className="space-y-8">
{/* Step Header */}
<StepHeader
stepId="document-generator"
title="Dokumentengenerator"
description="Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen"
explanation={stepInfo.explanation}
tips={stepInfo.tips}
/>
{/* Status bar */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="text-sm text-gray-500">Vorlagen gesamt</div>
<div className="text-3xl font-bold text-gray-900">{totalCount}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="text-sm text-gray-500">Angezeigt</div>
<div className="text-3xl font-bold text-purple-600">{filteredTemplates.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="text-sm text-gray-500">Aktive Vorlage</div>
<div className="text-sm font-medium text-gray-900 mt-1 truncate">
{activeTemplate ? activeTemplate.documentTitle : '—'}
</div>
</div>
</div>
{/* ================================================================= */}
{/* SECTION 1: TEMPLATE-BIBLIOTHEK */}
{/* ================================================================= */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{/* Library header */}
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3">
<h2 className="font-semibold text-gray-900">Template-Bibliothek</h2>
{/* Language toggle */}
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
{(['all', 'de', 'en'] as const).map((lang) => (
<button
key={lang}
onClick={() => setActiveLanguage(lang)}
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-colors ${
activeLanguage === lang
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{lang === 'all' ? 'Alle' : lang.toUpperCase()}
</button>
))}
</div>
</div>
{/* Category pills */}
<div className="px-6 py-3 border-b border-gray-100 flex gap-2 flex-wrap">
{CATEGORIES.map((cat) => {
const count = cat.types === null
? allTemplates.filter(t => activeLanguage === 'all' || t.language === activeLanguage).length
: allTemplates.filter(t =>
cat.types!.includes(t.templateType || '') &&
(activeLanguage === 'all' || t.language === activeLanguage)
).length
return (
<button
key={cat.key}
onClick={() => setActiveCategory(cat.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
activeCategory === cat.key
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{cat.label}
{count > 0 && (
<span className={`ml-1.5 ${activeCategory === cat.key ? 'text-purple-200' : 'text-gray-400'}`}>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Search */}
<div className="px-6 py-3 border-b border-gray-100">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={librarySearch}
onChange={(e) => setLibrarySearch(e.target.value)}
placeholder="Vorlage suchen... (optional)"
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
/>
{librarySearch && (
<button
onClick={() => setLibrarySearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
</div>
{/* Template grid */}
<div className="p-6">
{isLoadingLibrary ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : filteredTemplates.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="text-4xl mb-3">📄</div>
<p>Keine Vorlagen für diese Auswahl</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTemplates.map((template) => (
<LibraryCard
key={template.id}
template={template}
expanded={expandedPreviewId === template.id}
onTogglePreview={() =>
setExpandedPreviewId((prev) => (prev === template.id ? null : template.id))
}
onUse={() => handleUseTemplate(template)}
/>
))}
</div>
)}
</div>
</div>
{/* ================================================================= */}
{/* SECTION 2: GENERATOR (visible only when activeTemplate is set) */}
{/* ================================================================= */}
{activeTemplate && (
<div ref={generatorRef} className="space-y-6">
<GeneratorSection
template={activeTemplate}
placeholderValues={placeholderValues}
onPlaceholderChange={(key, value) =>
setPlaceholderValues((prev) => ({ ...prev, [key]: value }))
}
onClose={() => setActiveTemplate(null)}
/>
{/* Einwilligungen DataPoints sidebar (shown below generator on small screens, side on large) */}
{selectedDataPointsData && selectedDataPointsData.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<DocumentValidation
dataPoints={selectedDataPointsData}
documentContent={activeTemplate.text}
language="de"
onInsertPlaceholder={handleInsertPlaceholder}
/>
</div>
<div className="lg:col-span-1">
<DataPointsPreview
dataPoints={selectedDataPointsData}
onInsertPlaceholder={handleInsertPlaceholder}
language="de"
/>
</div>
</div>
)}
</div>
)}
</div>
)
}
export default function DocumentGeneratorPage() {
return (
<EinwilligungenProvider>
<DocumentGeneratorPageInner />
</EinwilligungenProvider>
)
}