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 37s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
Implementiert MIT-lizenzierte DSGVO-Templates (DSE, Impressum, AGB) in der eigenen PostgreSQL-Datenbank statt KLAUSUR_SERVICE-Abhängigkeit. - Migration 018: compliance_legal_templates Tabelle + 3 Seed-Templates - Routes: GET/POST/PUT/DELETE /legal-templates + /status + /sources - Registriert im bestehenden compliance catch-all Proxy (kein neuer Proxy) - searchTemplates.ts: eigenes Backend als Primary, RAG bleibt Fallback - ServiceMode-Banner: KLAUSUR_SERVICE-Referenz entfernt - Tests: 25 Python + 3 Vitest — alle grün Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
763 lines
31 KiB
TypeScript
763 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||
import { useSDK } from '@/lib/sdk'
|
||
import { useEinwilligungen } from '@/lib/sdk/einwilligungen/context'
|
||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||
import {
|
||
LegalTemplateResult,
|
||
TemplateType,
|
||
Jurisdiction,
|
||
LicenseType,
|
||
GeneratedDocument,
|
||
TEMPLATE_TYPE_LABELS,
|
||
LICENSE_TYPE_LABELS,
|
||
JURISDICTION_LABELS,
|
||
DEFAULT_PLACEHOLDERS,
|
||
} from '@/lib/sdk/types'
|
||
import { DataPointsPreview } from './components/DataPointsPreview'
|
||
import { DocumentValidation } from './components/DocumentValidation'
|
||
import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-helpers'
|
||
|
||
// =============================================================================
|
||
// API CLIENT (extracted to searchTemplates.ts for testability)
|
||
// =============================================================================
|
||
|
||
import { searchTemplates, getTemplatesStatus, getSources, RAG_PROXY } from './searchTemplates'
|
||
|
||
// =============================================================================
|
||
// COMPONENTS
|
||
// =============================================================================
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const colors: Record<string, string> = {
|
||
ready: 'bg-green-100 text-green-700',
|
||
empty: 'bg-yellow-100 text-yellow-700',
|
||
error: 'bg-red-100 text-red-700',
|
||
running: 'bg-blue-100 text-blue-700',
|
||
}
|
||
return (
|
||
<span className={`px-2 py-1 text-xs rounded-full ${colors[status] || 'bg-gray-100 text-gray-700'}`}>
|
||
{status}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function LicenseBadge({ licenseId, small = false }: { licenseId: LicenseType | null; small?: boolean }) {
|
||
if (!licenseId) return null
|
||
|
||
const colors: 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]}`}>
|
||
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function TemplateCard({
|
||
template,
|
||
selected,
|
||
onSelect,
|
||
}: {
|
||
template: LegalTemplateResult
|
||
selected: boolean
|
||
onSelect: () => void
|
||
}) {
|
||
return (
|
||
<div
|
||
onClick={onSelect}
|
||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||
selected
|
||
? 'border-purple-500 bg-purple-50'
|
||
: 'border-gray-200 hover:border-purple-300 bg-white'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-medium text-gray-900">
|
||
{template.documentTitle || 'Untitled'}
|
||
</span>
|
||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||
{template.score.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||
{template.templateType && (
|
||
<span className="text-xs text-purple-600 bg-purple-100 px-2 py-0.5 rounded">
|
||
{TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType}
|
||
</span>
|
||
)}
|
||
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
|
||
<span className="text-xs text-gray-500 uppercase">
|
||
{template.language}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected}
|
||
onChange={onSelect}
|
||
className="w-5 h-5 text-purple-600 rounded border-gray-300"
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-sm text-gray-600 line-clamp-3 mt-2">
|
||
{template.text}
|
||
</p>
|
||
|
||
{template.attributionRequired && template.attributionText && (
|
||
<div className="mt-2 text-xs text-orange-600 bg-orange-50 p-2 rounded">
|
||
Attribution: {template.attributionText}
|
||
</div>
|
||
)}
|
||
|
||
{template.placeholders && template.placeholders.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{template.placeholders.slice(0, 5).map((p, i) => (
|
||
<span key={i} className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
|
||
{p}
|
||
</span>
|
||
))}
|
||
{template.placeholders.length > 5 && (
|
||
<span className="text-xs text-gray-500">
|
||
+{template.placeholders.length - 5} more
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-3 text-xs text-gray-500">
|
||
Source: {template.sourceName}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PlaceholderEditor({
|
||
placeholders,
|
||
values,
|
||
onChange,
|
||
}: {
|
||
placeholders: string[]
|
||
values: Record<string, string>
|
||
onChange: (key: string, value: string) => void
|
||
}) {
|
||
if (placeholders.length === 0) return null
|
||
|
||
return (
|
||
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200">
|
||
<h4 className="font-medium text-blue-900 mb-3">Platzhalter ausfuellen</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{placeholders.map((placeholder) => (
|
||
<div key={placeholder}>
|
||
<label className="block text-sm text-blue-700 mb-1">{placeholder}</label>
|
||
<input
|
||
type="text"
|
||
value={values[placeholder] || ''}
|
||
onChange={(e) => onChange(placeholder, e.target.value)}
|
||
placeholder={`Wert fuer ${placeholder}`}
|
||
className="w-full px-3 py-2 border border-blue-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AttributionFooter({ templates }: { templates: LegalTemplateResult[] }) {
|
||
const attributionTemplates = templates.filter((t) => t.attributionRequired)
|
||
if (attributionTemplates.length === 0) return null
|
||
|
||
return (
|
||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||
<h4 className="font-medium text-gray-900 mb-2">Quellenangaben (werden automatisch hinzugefuegt)</h4>
|
||
<div className="text-sm text-gray-600 space-y-1">
|
||
<p>Dieses Dokument wurde unter Verwendung folgender Quellen erstellt:</p>
|
||
<ul className="list-disc list-inside ml-2">
|
||
{attributionTemplates.map((t, i) => (
|
||
<li key={i}>
|
||
{t.attributionText || `${t.sourceName} (${t.licenseName})`}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DocumentPreview({
|
||
content,
|
||
placeholders,
|
||
}: {
|
||
content: string
|
||
placeholders: Record<string, string>
|
||
}) {
|
||
// Replace placeholders in content
|
||
let processedContent = content
|
||
for (const [key, value] of Object.entries(placeholders)) {
|
||
if (value) {
|
||
processedContent = processedContent.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6 prose prose-sm max-w-none">
|
||
<div className="whitespace-pre-wrap">{processedContent}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =============================================================================
|
||
// MAIN PAGE
|
||
// =============================================================================
|
||
|
||
export default function DocumentGeneratorPage() {
|
||
const { state } = useSDK()
|
||
const { selectedDataPointsData } = useEinwilligungen()
|
||
|
||
// Status state
|
||
const [status, setStatus] = useState<any>(null)
|
||
const [sources, setSources] = useState<any[]>([])
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
type ServiceMode = 'loading' | 'full' | 'rag-only' | 'offline'
|
||
const [serviceMode, setServiceMode] = useState<ServiceMode>('loading')
|
||
|
||
// Search state
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [selectedType, setSelectedType] = useState<TemplateType | ''>('')
|
||
const [selectedLanguage, setSelectedLanguage] = useState<'de' | 'en' | ''>('')
|
||
const [selectedJurisdiction, setSelectedJurisdiction] = useState<Jurisdiction | ''>('')
|
||
const [searchResults, setSearchResults] = useState<LegalTemplateResult[]>([])
|
||
const [isSearching, setIsSearching] = useState(false)
|
||
|
||
// Selection state
|
||
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
|
||
|
||
// Editor state
|
||
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>({})
|
||
const [activeTab, setActiveTab] = useState<'search' | 'compose' | 'preview'>('search')
|
||
|
||
// Load initial status
|
||
useEffect(() => {
|
||
async function loadStatus() {
|
||
try {
|
||
const [statusData, sourcesData] = await Promise.all([
|
||
getTemplatesStatus(),
|
||
getSources(),
|
||
])
|
||
setStatus(statusData)
|
||
setSources(sourcesData)
|
||
const hasTemplateDb = statusData !== null
|
||
const hasRag = await fetch(`${RAG_PROXY}/regulations`).then(r => r.ok).catch(() => false)
|
||
setServiceMode(hasTemplateDb ? 'full' : hasRag ? 'rag-only' : 'offline')
|
||
} catch {
|
||
setServiceMode('offline')
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
loadStatus()
|
||
}, [])
|
||
|
||
// Pre-fill placeholders from company profile
|
||
useEffect(() => {
|
||
if (state?.companyProfile) {
|
||
const profile = state.companyProfile
|
||
setPlaceholderValues((prev) => ({
|
||
...prev,
|
||
'[COMPANY_NAME]': profile.companyName || '',
|
||
'[FIRMENNAME]': profile.companyName || '',
|
||
'[EMAIL]': profile.dpoEmail || '',
|
||
'[DSB_EMAIL]': profile.dpoEmail || '',
|
||
'[DPO_NAME]': profile.dpoName || '',
|
||
'[DSB_NAME]': profile.dpoName || '',
|
||
}))
|
||
}
|
||
}, [state?.companyProfile])
|
||
|
||
// Pre-fill placeholders from Einwilligungen data points
|
||
useEffect(() => {
|
||
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
||
const einwilligungenPlaceholders = generateAllPlaceholders(selectedDataPointsData, 'de')
|
||
setPlaceholderValues((prev) => ({
|
||
...prev,
|
||
...einwilligungenPlaceholders,
|
||
}))
|
||
}
|
||
}, [selectedDataPointsData])
|
||
|
||
// Handler for inserting placeholders from DataPointsPreview
|
||
const handleInsertPlaceholder = useCallback((placeholder: string) => {
|
||
// This is a simplified version - in a real editor you would insert at cursor position
|
||
// For now, we just ensure the placeholder is in the values so it can be replaced
|
||
if (!placeholderValues[placeholder]) {
|
||
// The placeholder value will be generated from einwilligungen data
|
||
const einwilligungenPlaceholders = generateAllPlaceholders(selectedDataPointsData || [], 'de')
|
||
if (einwilligungenPlaceholders[placeholder as keyof typeof einwilligungenPlaceholders]) {
|
||
setPlaceholderValues((prev) => ({
|
||
...prev,
|
||
[placeholder]: einwilligungenPlaceholders[placeholder as keyof typeof einwilligungenPlaceholders],
|
||
}))
|
||
}
|
||
}
|
||
}, [placeholderValues, selectedDataPointsData])
|
||
|
||
// Search handler
|
||
const handleSearch = useCallback(async () => {
|
||
if (!searchQuery.trim()) return
|
||
|
||
setIsSearching(true)
|
||
try {
|
||
const results = await searchTemplates({
|
||
query: searchQuery,
|
||
templateType: selectedType || undefined,
|
||
language: selectedLanguage || undefined,
|
||
jurisdiction: selectedJurisdiction || undefined,
|
||
limit: 20,
|
||
})
|
||
setSearchResults(results)
|
||
} catch (error) {
|
||
console.error('Search failed:', error)
|
||
} finally {
|
||
setIsSearching(false)
|
||
}
|
||
}, [searchQuery, selectedType, selectedLanguage, selectedJurisdiction])
|
||
|
||
// Toggle template selection
|
||
const toggleTemplate = (id: string) => {
|
||
setSelectedTemplates((prev) =>
|
||
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
|
||
)
|
||
}
|
||
|
||
// Get selected template objects
|
||
const selectedTemplateObjects = searchResults.filter((r) =>
|
||
selectedTemplates.includes(r.id)
|
||
)
|
||
|
||
// Get all unique placeholders from selected templates
|
||
const allPlaceholders = Array.from(
|
||
new Set(selectedTemplateObjects.flatMap((t) => t.placeholders || []))
|
||
)
|
||
|
||
// Combined content from selected templates
|
||
const combinedContent = selectedTemplateObjects
|
||
.map((t) => `## ${t.documentTitle || 'Abschnitt'}\n\n${t.text}`)
|
||
.join('\n\n---\n\n')
|
||
|
||
// Step info
|
||
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: ['Waehlen Sie passende Vorlagen aus der Suche', 'Fuellen Sie die Platzhalter mit Ihren Unternehmensdaten'],
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Step Header */}
|
||
<StepHeader
|
||
stepId="document-generator"
|
||
title="Dokumentengenerator"
|
||
description="Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen"
|
||
explanation={stepInfo.explanation}
|
||
tips={stepInfo.tips}
|
||
>
|
||
<button
|
||
onClick={() => setActiveTab('compose')}
|
||
disabled={selectedTemplates.length === 0}
|
||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<svg className="w-5 h-5" 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>
|
||
Dokument erstellen ({selectedTemplates.length})
|
||
</button>
|
||
</StepHeader>
|
||
|
||
{/* Service mode banners */}
|
||
{serviceMode === 'rag-only' && (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
|
||
⚠️ Template-Datenbank nicht erreichbar — Suche läuft über RAG-Fallback
|
||
</div>
|
||
)}
|
||
{serviceMode === 'offline' && (
|
||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800">
|
||
❌ Keine Template-Services erreichbar. Stellen Sie sicher, dass breakpilot-core oder ai-compliance-sdk läuft.
|
||
</div>
|
||
)}
|
||
|
||
{/* Status Overview */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="text-sm text-gray-500">Collection Status</div>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<StatusBadge status={status?.stats?.status || 'unknown'} />
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="text-sm text-gray-500">Indexierte Chunks</div>
|
||
<div className="text-3xl font-bold text-gray-900">
|
||
{status?.stats?.points_count || 0}
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="text-sm text-gray-500">Aktive Quellen</div>
|
||
<div className="text-3xl font-bold text-purple-600">
|
||
{sources.filter((s) => s.enabled).length}
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="text-sm text-gray-500">Ausgewaehlt</div>
|
||
<div className="text-3xl font-bold text-blue-600">
|
||
{selectedTemplates.length}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className="flex gap-2 border-b border-gray-200">
|
||
{(['search', 'compose', 'preview'] as const).map((tab) => (
|
||
<button
|
||
key={tab}
|
||
onClick={() => setActiveTab(tab)}
|
||
disabled={tab !== 'search' && selectedTemplates.length === 0}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === tab
|
||
? 'text-purple-600 border-b-2 border-purple-600'
|
||
: 'text-gray-500 hover:text-gray-700 disabled:text-gray-300 disabled:cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{tab === 'search' && 'Vorlagen suchen'}
|
||
{tab === 'compose' && 'Zusammenstellen'}
|
||
{tab === 'preview' && 'Vorschau'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Search Tab */}
|
||
{activeTab === 'search' && (
|
||
<div className="space-y-4">
|
||
{/* Search Form */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="flex gap-4 items-end flex-wrap">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Suche
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
placeholder="z.B. Datenschutzerklaerung, Cookie-Banner, Widerruf..."
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Dokumenttyp
|
||
</label>
|
||
<select
|
||
value={selectedType}
|
||
onChange={(e) => setSelectedType(e.target.value as TemplateType | '')}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
>
|
||
<option value="">Alle Typen</option>
|
||
{Object.entries(TEMPLATE_TYPE_LABELS).map(([key, label]) => (
|
||
<option key={key} value={key}>{label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Sprache
|
||
</label>
|
||
<select
|
||
value={selectedLanguage}
|
||
onChange={(e) => setSelectedLanguage(e.target.value as 'de' | 'en' | '')}
|
||
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
>
|
||
<option value="">Alle</option>
|
||
<option value="de">Deutsch</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</div>
|
||
<button
|
||
onClick={handleSearch}
|
||
disabled={isSearching || !searchQuery.trim()}
|
||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||
>
|
||
{isSearching ? 'Suche...' : 'Suchen'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search Results */}
|
||
{searchResults.length > 0 && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-semibold text-gray-900">
|
||
{searchResults.length} Ergebnisse
|
||
</h3>
|
||
{selectedTemplates.length > 0 && (
|
||
<button
|
||
onClick={() => setSelectedTemplates([])}
|
||
className="text-sm text-gray-500 hover:text-gray-700"
|
||
>
|
||
Auswahl aufheben
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{searchResults.map((result) => (
|
||
<TemplateCard
|
||
key={result.id}
|
||
template={result}
|
||
selected={selectedTemplates.includes(result.id)}
|
||
onSelect={() => toggleTemplate(result.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{searchResults.length === 0 && searchQuery && !isSearching && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||
<svg className="w-8 h-8 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>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Keine Vorlagen gefunden</h3>
|
||
<p className="mt-2 text-gray-500">
|
||
Versuchen Sie einen anderen Suchbegriff oder aendern Sie die Filter.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick Start Templates */}
|
||
{searchResults.length === 0 && !searchQuery && (
|
||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">Schnellstart - Haeufig benoetigte Dokumente</h3>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
{[
|
||
{ query: 'Datenschutzerklaerung DSGVO', type: 'privacy_policy', icon: '🔒' },
|
||
{ query: 'Cookie Banner', type: 'cookie_banner', icon: '🍪' },
|
||
{ query: 'Impressum', type: 'impressum', icon: '📋' },
|
||
{ query: 'AGB Nutzungsbedingungen', type: 'terms_of_service', icon: '📜' },
|
||
].map((item) => (
|
||
<button
|
||
key={item.type}
|
||
onClick={() => {
|
||
setSearchQuery(item.query)
|
||
setSelectedType(item.type as TemplateType)
|
||
setTimeout(handleSearch, 100)
|
||
}}
|
||
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center"
|
||
>
|
||
<span className="text-3xl mb-2 block">{item.icon}</span>
|
||
<span className="text-sm font-medium text-gray-900">
|
||
{TEMPLATE_TYPE_LABELS[item.type as TemplateType]}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Compose Tab */}
|
||
{activeTab === 'compose' && selectedTemplates.length > 0 && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Main Content - 2/3 */}
|
||
<div className="lg:col-span-2 space-y-6">
|
||
{/* Selected Templates */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">
|
||
Ausgewaehlte Bausteine ({selectedTemplates.length})
|
||
</h3>
|
||
<div className="space-y-2">
|
||
{selectedTemplateObjects.map((t, index) => (
|
||
<div
|
||
key={t.id}
|
||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-gray-400 font-mono">{index + 1}.</span>
|
||
<span className="font-medium">{t.documentTitle}</span>
|
||
<LicenseBadge licenseId={t.licenseId as LicenseType} small />
|
||
</div>
|
||
<button
|
||
onClick={() => toggleTemplate(t.id)}
|
||
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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Placeholder Editor */}
|
||
<PlaceholderEditor
|
||
placeholders={allPlaceholders}
|
||
values={placeholderValues}
|
||
onChange={(key, value) =>
|
||
setPlaceholderValues((prev) => ({ ...prev, [key]: value }))
|
||
}
|
||
/>
|
||
|
||
{/* Attribution Footer */}
|
||
<AttributionFooter templates={selectedTemplateObjects} />
|
||
|
||
{/* Actions */}
|
||
<div className="flex justify-end gap-4">
|
||
<button
|
||
onClick={() => setActiveTab('search')}
|
||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||
>
|
||
Zurueck zur Suche
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('preview')}
|
||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||
>
|
||
Vorschau anzeigen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sidebar - 1/3: Einwilligungen DataPoints */}
|
||
<div className="lg:col-span-1">
|
||
<DataPointsPreview
|
||
dataPoints={selectedDataPointsData || []}
|
||
onInsertPlaceholder={handleInsertPlaceholder}
|
||
language="de"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview Tab */}
|
||
{activeTab === 'preview' && selectedTemplates.length > 0 && (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-semibold text-gray-900">Dokument-Vorschau</h3>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setActiveTab('compose')}
|
||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
// Copy to clipboard
|
||
let content = combinedContent
|
||
for (const [key, value] of Object.entries(placeholderValues)) {
|
||
if (value) {
|
||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||
}
|
||
}
|
||
navigator.clipboard.writeText(content)
|
||
}}
|
||
className="px-4 py-2 text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
|
||
>
|
||
Kopieren
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const printWindow = window.open('', '_blank')
|
||
if (!printWindow) return
|
||
let content = combinedContent
|
||
for (const [key, value] of Object.entries(placeholderValues)) {
|
||
if (value) {
|
||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||
}
|
||
}
|
||
const attributions = selectedTemplateObjects
|
||
.filter(t => t.attributionRequired && t.attributionText)
|
||
.map(t => `<li>${t.attributionText}</li>`)
|
||
.join('')
|
||
printWindow.document.write(`<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Datenschutzdokument</title><style>body{font-family:Arial,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;color:#222}h2{border-bottom:1px solid #ccc;padding-bottom:8px;margin-top:32px}hr{border:none;border-top:1px solid #eee;margin:24px 0}.attribution{font-size:12px;color:#666;margin-top:48px;border-top:1px solid #ddd;padding-top:16px}@media print{body{margin:0}}</style></head><body><div style="white-space:pre-wrap">${content.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}</div>${attributions ? `<div class="attribution"><strong>Quellenangaben:</strong><ul>${attributions}</ul></div>` : ''}</body></html>`)
|
||
printWindow.document.close()
|
||
printWindow.focus()
|
||
setTimeout(() => printWindow.print(), 500)
|
||
}}
|
||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||
>
|
||
Als PDF exportieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Document Validation based on selected Einwilligungen */}
|
||
{selectedDataPointsData && selectedDataPointsData.length > 0 && (
|
||
<DocumentValidation
|
||
dataPoints={selectedDataPointsData}
|
||
documentContent={combinedContent}
|
||
language="de"
|
||
onInsertPlaceholder={handleInsertPlaceholder}
|
||
/>
|
||
)}
|
||
|
||
<DocumentPreview
|
||
content={combinedContent}
|
||
placeholders={placeholderValues}
|
||
/>
|
||
|
||
{/* Attribution */}
|
||
<AttributionFooter templates={selectedTemplateObjects} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Sources Info */}
|
||
{activeTab === 'search' && sources.length > 0 && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">Verfuegbare Quellen</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{sources.filter((s) => s.enabled).slice(0, 6).map((source) => (
|
||
<div key={source.name} className="p-4 bg-gray-50 rounded-lg">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="font-medium text-gray-900">{source.name}</span>
|
||
<LicenseBadge licenseId={source.license_type as LicenseType} small />
|
||
</div>
|
||
<p className="text-sm text-gray-600 line-clamp-2">{source.description}</p>
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{source.template_types.slice(0, 3).map((t: string) => (
|
||
<span key={t} className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
|
||
{TEMPLATE_TYPE_LABELS[t as TemplateType] || t}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|