Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
794 lines
30 KiB
TypeScript
794 lines
30 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
|
|
// =============================================================================
|
|
|
|
const KLAUSUR_SERVICE_URL = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
|
|
|
async function searchTemplates(params: {
|
|
query: string
|
|
templateType?: TemplateType
|
|
licenseTypes?: LicenseType[]
|
|
language?: 'de' | 'en'
|
|
jurisdiction?: Jurisdiction
|
|
limit?: number
|
|
}): Promise<LegalTemplateResult[]> {
|
|
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/search`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
query: params.query,
|
|
template_type: params.templateType,
|
|
license_types: params.licenseTypes,
|
|
language: params.language,
|
|
jurisdiction: params.jurisdiction,
|
|
limit: params.limit || 10,
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Search failed')
|
|
}
|
|
|
|
const data = await response.json()
|
|
return data.map((r: any) => ({
|
|
id: r.id,
|
|
score: r.score,
|
|
text: r.text,
|
|
documentTitle: r.document_title,
|
|
templateType: r.template_type,
|
|
clauseCategory: r.clause_category,
|
|
language: r.language,
|
|
jurisdiction: r.jurisdiction,
|
|
licenseId: r.license_id,
|
|
licenseName: r.license_name,
|
|
licenseUrl: r.license_url,
|
|
attributionRequired: r.attribution_required,
|
|
attributionText: r.attribution_text,
|
|
sourceName: r.source_name,
|
|
sourceUrl: r.source_url,
|
|
sourceRepo: r.source_repo,
|
|
placeholders: r.placeholders || [],
|
|
isCompleteDocument: r.is_complete_document,
|
|
isModular: r.is_modular,
|
|
requiresCustomization: r.requires_customization,
|
|
outputAllowed: r.output_allowed ?? true,
|
|
modificationAllowed: r.modification_allowed ?? true,
|
|
distortionProhibited: r.distortion_prohibited ?? false,
|
|
}))
|
|
}
|
|
|
|
async function getTemplatesStatus(): Promise<any> {
|
|
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/status`)
|
|
if (!response.ok) return null
|
|
return response.json()
|
|
}
|
|
|
|
async function getSources(): Promise<any[]> {
|
|
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/sources`)
|
|
if (!response.ok) return []
|
|
const data = await response.json()
|
|
return data.sources || []
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)
|
|
|
|
// 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)
|
|
} catch (error) {
|
|
console.error('Failed to load status:', error)
|
|
} 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 - using 'consent' as base since document-generator doesn't exist yet
|
|
const stepInfo = STEP_EXPLANATIONS['consent'] || {
|
|
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>
|
|
|
|
{/* 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
|
|
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>
|
|
)
|
|
}
|