dsfa/[id]/page.tsx (1893 LOC -> 350 LOC) split into 9 components: Section1-5Editor, SDMCoverageOverview, RAGSearchPanel, AddRiskModal, AddMitigationModal. Page is now a thin orchestrator. notfallplan/page.tsx (1890 LOC -> 435 LOC) split into 8 modules: types.ts, ConfigTab, IncidentsTab, TemplatesTab, ExercisesTab, Modals, ApiSections. All under the 500-line hard cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState } from 'react'
|
|
|
|
interface RAGSearchResult {
|
|
text: string
|
|
regulation_code: string
|
|
regulation_name: string
|
|
regulation_short: string
|
|
category: string
|
|
article?: string
|
|
source_url?: string
|
|
score: number
|
|
}
|
|
|
|
interface RAGSearchResponse {
|
|
query: string
|
|
results: RAGSearchResult[]
|
|
count: number
|
|
}
|
|
|
|
export function RAGSearchPanel({
|
|
context,
|
|
categories,
|
|
onInsertText,
|
|
}: {
|
|
context: string
|
|
categories?: string[]
|
|
onInsertText?: (text: string) => void
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [results, setResults] = useState<RAGSearchResponse | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
|
|
|
const buildQuery = () => {
|
|
if (query.trim()) return query.trim()
|
|
return context.substring(0, 200)
|
|
}
|
|
|
|
const handleSearch = async () => {
|
|
const searchQuery = buildQuery()
|
|
if (!searchQuery || searchQuery.length < 3) return
|
|
|
|
setIsSearching(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const response = await fetch('/api/sdk/v1/rag/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
query: searchQuery,
|
|
collection: 'bp_dsfa_corpus',
|
|
top_k: 5,
|
|
}),
|
|
})
|
|
if (!response.ok) throw new Error(`Suche fehlgeschlagen (${response.status})`)
|
|
const data: RAGSearchResponse = await response.json()
|
|
setResults(data)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen')
|
|
setResults(null)
|
|
} finally {
|
|
setIsSearching(false)
|
|
}
|
|
}
|
|
|
|
const handleInsert = (text: string, resultId: string) => {
|
|
if (onInsertText) {
|
|
onInsertText(text)
|
|
} else {
|
|
navigator.clipboard.writeText(text)
|
|
}
|
|
setCopiedId(resultId)
|
|
setTimeout(() => setCopiedId(null), 2000)
|
|
}
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-indigo-50 text-indigo-700 rounded-lg border border-indigo-200 hover:bg-indigo-100 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" 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>
|
|
Empfehlung suchen (RAG)
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium text-indigo-800 flex items-center gap-2">
|
|
<svg className="w-4 h-4" 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>
|
|
DSFA-Wissenssuche (RAG)
|
|
</h4>
|
|
<button
|
|
onClick={() => { setIsOpen(false); setResults(null); setError(null) }}
|
|
className="text-indigo-400 hover:text-indigo-600"
|
|
>
|
|
<svg className="w-4 h-4" 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>
|
|
|
|
{/* Search Input */}
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
placeholder={`Suchbegriff (oder leer fuer automatische Kontextsuche)...`}
|
|
className="flex-1 px-3 py-2 text-sm border border-indigo-300 rounded-lg bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={isSearching}
|
|
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{isSearching ? 'Suche...' : 'Suchen'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{results && results.results.length > 0 && (
|
|
<div className="space-y-3">
|
|
<p className="text-xs text-indigo-600">{results.count} Ergebnis(se) gefunden</p>
|
|
|
|
{results.results.map((r, idx) => {
|
|
const resultId = `${r.regulation_code}-${idx}`
|
|
return (
|
|
<div key={resultId} className="bg-white rounded-lg border border-indigo-100 p-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-indigo-600 mb-1">
|
|
{r.regulation_name}{r.article ? ` — ${r.article}` : ''}
|
|
</div>
|
|
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">
|
|
{r.text.length > 400 ? r.text.substring(0, 400) + '...' : r.text}
|
|
</p>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className="text-xs text-gray-400 font-mono">
|
|
{r.regulation_short || r.regulation_code} ({(r.score * 100).toFixed(0)}%)
|
|
</span>
|
|
{r.category && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{r.category}</span>
|
|
)}
|
|
{r.source_url && (
|
|
<a href={r.source_url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 hover:underline">Quelle</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleInsert(r.text, resultId)}
|
|
className={`flex-shrink-0 px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
|
copiedId === resultId
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
|
|
}`}
|
|
title="In Beschreibung uebernehmen"
|
|
>
|
|
{copiedId === resultId ? 'Kopiert!' : 'Uebernehmen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{results && results.results.length === 0 && (
|
|
<div className="text-sm text-indigo-600 text-center py-4">
|
|
Keine Ergebnisse gefunden. Versuchen Sie einen anderen Suchbegriff.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|