[split-required] Split website + studio-v2 monoliths (Phase 3 continued)

Website (14 monoliths split):
- compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20)
- quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11)
- i18n.ts (1,173 → 8 language files)
- unity-bridge (1,094 → 12), backlog (1,087 → 6)
- training (1,066 → 8), rag (1,063 → 8)
- Deleted index_original.ts (4,899 LOC dead backup)

Studio-v2 (5 monoliths split):
- meet/page.tsx (1,481 → 9), messages (1,166 → 9)
- AlertsB2BContext.tsx (1,165 → 5 modules)
- alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6)

All existing imports preserved. Zero new TypeScript errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:52:36 +02:00
parent b681ddb131
commit 0b37c5e692
143 changed files with 15822 additions and 15889 deletions

View File

@@ -0,0 +1,123 @@
'use client'
import type { Collection } from './types'
function CollectionCard({ collection }: { collection: Collection }) {
const statusColors = {
ready: 'bg-green-100 text-green-800',
indexing: 'bg-yellow-100 text-yellow-800',
empty: 'bg-slate-100 text-slate-800',
}
const statusLabels = {
ready: 'Bereit',
indexing: 'Indexierung...',
empty: 'Leer',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{collection.displayName}</h3>
<p className="text-sm text-slate-500 font-mono">{collection.name}</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
{statusLabels[collection.status]}
</span>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Chunks</p>
<p className="text-lg font-semibold text-slate-900">{collection.chunkCount.toLocaleString()}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Jahre</p>
<p className="text-lg font-semibold text-slate-900">
{collection.years.length > 0 ? `${Math.min(...collection.years)}-${Math.max(...collection.years)}` : '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Fächer</p>
<p className="text-lg font-semibold text-slate-900">{collection.subjects.length}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Bundesland</p>
<p className="text-lg font-semibold text-slate-900">{collection.bundesland}</p>
</div>
</div>
{collection.subjects.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{collection.subjects.slice(0, 8).map((subject) => (
<span key={subject} className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded-md">{subject}</span>
))}
{collection.subjects.length > 8 && (
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-xs rounded-md">+{collection.subjects.length - 8} weitere</span>
)}
</div>
)}
</div>
)
}
export function CollectionsTab({
collections,
loading,
onRefresh,
}: {
collections: Collection[]
loading: boolean
onRefresh: () => void
}) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Sammlungen</h2>
<p className="text-sm text-slate-500">Verwaltung der indexierten Dokumentensammlungen</p>
</div>
<button onClick={onRefresh} className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">
Aktualisieren
</button>
</div>
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
{!loading && (
<div className="grid gap-4">
{collections.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Sammlungen vorhanden</h3>
<p className="text-slate-500 mb-4">Starten Sie die Ingestion oder laden Sie Dokumente hoch.</p>
</div>
) : (
collections.map((col) => <CollectionCard key={col.name} collection={col} />)
)}
<div className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-slate-400 transition-colors cursor-pointer">
<svg className="w-8 h-8 text-slate-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-sm font-medium text-slate-600">Neue Sammlung (demnächst)</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import type { IngestionStatus } from './types'
import { API_BASE } from './types'
export function IngestionTab({
status,
onRefresh,
}: {
status: IngestionStatus | null
onRefresh: () => void
}) {
const [starting, setStarting] = useState(false)
const startIngestion = async () => {
setStarting(true)
try {
await fetch(`${API_BASE}/api/v1/admin/nibis/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ewh_only: true }),
})
onRefresh()
} catch (err) {
console.error('Failed to start ingestion:', err)
} finally {
setStarting(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">Ingestion Status</h2>
<p className="text-sm text-slate-500">Übersicht über laufende und vergangene Indexierungsvorgänge</p>
</div>
<div className="flex gap-3">
<button onClick={onRefresh} className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">
Aktualisieren
</button>
<button
onClick={startIngestion}
disabled={status?.running || starting}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{starting ? 'Startet...' : 'Ingestion starten'}
</button>
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="flex items-center gap-4 mb-4">
{status?.running ? (
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
) : (
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
)}
<span className="text-lg font-medium text-slate-900">
{status?.running ? 'Indexierung läuft...' : 'Bereit'}
</span>
</div>
{status && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Ausführung</p>
<p className="text-lg font-semibold text-slate-900">{status.lastRun ? new Date(status.lastRun).toLocaleString('de-DE') : '-'}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Dokumente</p>
<p className="text-lg font-semibold text-slate-900">{status.documentsIndexed ?? '-'}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Chunks</p>
<p className="text-lg font-semibold text-slate-900">{status.chunksCreated ?? '-'}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Fehler</p>
<p className={`text-lg font-semibold ${status.errors.length > 0 ? 'text-red-600' : 'text-slate-900'}`}>{status.errors.length}</p>
</div>
</div>
)}
{status?.errors && status.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{status.errors.slice(0, 5).map((error, i) => (<li key={i}>{error}</li>))}
{status.errors.length > 5 && (<li className="text-red-500">... und {status.errors.length - 5} weitere</li>)}
</ul>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { useState, useEffect } from 'react'
import type { Collection } from './types'
import { API_BASE } from './types'
function MetricCard({ title, value, change, positive }: {
title: string
value: string
change?: string
positive?: boolean
}) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-4">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<div className="flex items-end gap-2">
<p className="text-2xl font-bold text-slate-900">{value}</p>
{change && (
<span className={`text-sm font-medium ${positive ? 'text-green-600' : 'text-red-600'}`}>{change}</span>
)}
</div>
</div>
)
}
function ScoreBar({ label, percent, color }: { label: string; percent: number; color: string }) {
return (
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600 w-16">{label}</span>
<div className="flex-1 h-4 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full ${color} transition-all`} style={{ width: `${percent}%` }} />
</div>
<span className="text-sm text-slate-500 w-12 text-right">{percent}%</span>
</div>
)
}
export function MetricsTab({ collections }: { collections: Collection[] }) {
const [metrics, setMetrics] = useState({
precision: 0,
recall: 0,
mrr: 0,
avgLatency: 0,
totalRatings: 0,
errorRate: 0,
scoreDistribution: { '0.9+': 0, '0.7-0.9': 0, '0.5-0.7': 0, '<0.5': 0 },
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchMetrics = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/metrics`)
if (res.ok) {
const data = await res.json()
setMetrics({
precision: data.precision_at_5 || 0,
recall: data.recall_at_10 || 0,
mrr: data.mrr || 0,
avgLatency: data.avg_latency_ms || 0,
totalRatings: data.total_ratings || 0,
errorRate: data.error_rate || 0,
scoreDistribution: data.score_distribution || {},
})
}
} catch (err) {
console.error('Failed to fetch metrics:', err)
} finally {
setLoading(false)
}
}
fetchMetrics()
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Qualitätsmetriken</h2>
<p className="text-sm text-slate-500">Übersicht über Retrieval-Performance und Nutzerbewertungen</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<MetricCard title="Precision@5" value={`${(metrics.precision * 100).toFixed(0)}%`} change="+5%" positive />
<MetricCard title="Recall@10" value={`${(metrics.recall * 100).toFixed(0)}%`} change="+3%" positive />
<MetricCard title="MRR" value={metrics.mrr.toFixed(2)} change="-2%" positive={false} />
<MetricCard title="Avg. Latenz" value={`${metrics.avgLatency}ms`} />
<MetricCard title="Bewertungen" value={metrics.totalRatings.toString()} />
<MetricCard title="Fehlerrate" value={`${metrics.errorRate}%`} />
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Score-Verteilung</h3>
{loading ? (
<div className="flex justify-center py-4"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div></div>
) : (
<div className="space-y-3">
<ScoreBar label="0.9+" percent={metrics.scoreDistribution['0.9+'] || 0} color="bg-green-500" />
<ScoreBar label="0.7-0.9" percent={metrics.scoreDistribution['0.7-0.9'] || 0} color="bg-green-400" />
<ScoreBar label="0.5-0.7" percent={metrics.scoreDistribution['0.5-0.7'] || 0} color="bg-yellow-400" />
<ScoreBar label="<0.5" percent={metrics.scoreDistribution['<0.5'] || 0} color="bg-red-400" />
</div>
)}
</div>
<div className="flex gap-4">
<button className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Export CSV</button>
<button className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">Detailbericht</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useState } from 'react'
import type { Collection, SearchResult } from './types'
import { API_BASE } from './types'
export function SearchTab({ collections }: { collections: Collection[] }) {
const [query, setQuery] = useState('')
const [subject, setSubject] = useState<string>('')
const [year, setYear] = useState<string>('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [latency, setLatency] = useState<number | null>(null)
const [ratings, setRatings] = useState<Record<string, number>>({})
const submitRating = async (resultId: string, rating: number) => {
setRatings(prev => ({ ...prev, [resultId]: rating }))
try {
const formData = new FormData()
formData.append('result_id', resultId)
formData.append('rating', rating.toString())
await fetch(`${API_BASE}/api/v1/admin/rag/search/feedback`, { method: 'POST', body: formData })
} catch (err) {
console.error('Failed to submit rating:', err)
}
}
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
const startTime = Date.now()
try {
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.trim(), subject: subject || undefined, year: year ? parseInt(year) : undefined, limit: 10 }),
})
if (res.ok) {
const data = await res.json()
setResults(data)
setLatency(Date.now() - startTime)
}
} catch (err) {
console.error('Search failed:', err)
} finally {
setSearching(false)
}
}
const subjects = collections.flatMap(c => c.subjects).filter((v, i, a) => a.indexOf(v) === i).sort()
const years = collections.flatMap(c => c.years).filter((v, i, a) => a.indexOf(v) === i).sort()
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Suche & Qualitätstest</h2>
<p className="text-sm text-slate-500">Testen Sie die semantische Suche und bewerten Sie die Ergebnisqualität</p>
</div>
{/* Search Form */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Suchanfrage</label>
<input
type="text" value={query} onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. Analyse eines Gedichts von Rilke"
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div className="flex flex-wrap gap-4">
<div className="w-48">
<label className="block text-sm font-medium text-slate-700 mb-2">Fach</label>
<select value={subject} onChange={(e) => setSubject(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500">
<option value="">Alle Fächer</option>
{subjects.map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
</div>
<div className="w-32">
<label className="block text-sm font-medium text-slate-700 mb-2">Jahr</label>
<select value={year} onChange={(e) => setYear(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500">
<option value="">Alle Jahre</option>
{years.map((y) => (<option key={y} value={y}>{y}</option>))}
</select>
</div>
<div className="flex items-end">
<button onClick={handleSearch} disabled={searching || !query.trim()} className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
{searching ? (<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>) : (
<svg className="w-5 h-5" 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>
)}
Suchen
</button>
</div>
</div>
</div>
</div>
{/* Results */}
{results.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">{results.length} Ergebnisse</h3>
{latency && <span className="text-sm text-slate-500">Latenz: {latency}ms</span>}
</div>
{results.map((result, index) => (
<div key={result.id} className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-slate-400">#{index + 1}</span>
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-sm rounded font-mono">Score: {result.score.toFixed(3)}</span>
</div>
<div className="flex gap-2">
{result.year && <span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{result.year}</span>}
{result.subject && <span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{result.subject}</span>}
{result.niveau && <span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{result.niveau}</span>}
</div>
</div>
<p className="text-sm text-slate-700 leading-relaxed">{result.text}</p>
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-4">
<span className="text-sm text-slate-500">Relevanz:</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((starRating) => (
<button key={starRating} onClick={() => submitRating(result.id, starRating)} className={`p-1 transition-colors ${(ratings[result.id] || 0) >= starRating ? 'text-yellow-500' : 'text-slate-300 hover:text-yellow-400'}`} title={`${starRating} Sterne`}>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
</button>
))}
</div>
{ratings[result.id] && <span className="text-xs text-green-600">Bewertet</span>}
</div>
</div>
))}
</div>
)}
{results.length === 0 && query && !searching && (
<div className="bg-slate-50 rounded-lg p-8 text-center"><p className="text-slate-500">Keine Ergebnisse gefunden</p></div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useState, useCallback } from 'react'
import { API_BASE } from './types'
export function UploadTab({ onUploadComplete }: { onUploadComplete: () => void }) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
const validFiles = droppedFiles.filter(f => f.name.endsWith('.zip') || f.name.endsWith('.pdf'))
setFiles(prev => [...prev, ...validFiles])
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(prev => [...prev, ...Array.from(e.target.files!)])
}
}, [])
const removeFile = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
}, [])
const handleUpload = async () => {
if (files.length === 0) return
setUploading(true)
try {
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
formData.append('collection', 'bp_nibis_eh')
await fetch(`${API_BASE}/api/v1/admin/rag/upload`, { method: 'POST', body: formData })
}
setFiles([])
onUploadComplete()
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Dokumente hochladen</h2>
<p className="text-sm text-slate-500">ZIP-Archive oder einzelne PDFs hochladen. ZIPs werden automatisch entpackt.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Ziel-Sammlung</label>
<select className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="bp_nibis_eh">Niedersachsen - Klausurkorrektur</option>
<option value="bp_ni_zeugnis" disabled>Niedersachsen - Zeugnisgenerator (demnächst)</option>
</select>
</div>
{/* Drop Zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
isDragging ? 'border-primary-500 bg-primary-50' : 'border-slate-300 hover:border-slate-400'
}`}
>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-lg font-medium text-slate-700 mb-2">ZIP-Datei oder Ordner hierher ziehen</p>
<p className="text-sm text-slate-500 mb-4">oder</p>
<label className="cursor-pointer">
<span className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50">Dateien auswählen</span>
<input type="file" multiple accept=".zip,.pdf" onChange={handleFileSelect} className="hidden" />
</label>
<p className="text-xs text-slate-400 mt-4">Unterstützt: .zip, .pdf</p>
</div>
{/* File Queue */}
{files.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-slate-700">Upload-Queue ({files.length})</h3>
{files.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<p className="text-sm font-medium text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<button onClick={() => removeFile(index)} className="p-1 text-slate-400 hover:text-red-500">
<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>
))}
<button
onClick={handleUpload}
disabled={uploading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{uploading ? (
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Wird hochgeladen...</>
) : (
<>
<svg className="w-5 h-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-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Hochladen & Indexieren
</>
)}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { TabId } from './types'
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'collections',
name: 'Sammlungen',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
id: 'upload',
name: 'Upload',
icon: (
<svg className="w-5 h-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-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'ingestion',
name: 'Ingestion',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
},
{
id: 'search',
name: 'Suche & Test',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
id: 'metrics',
name: 'Metriken',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
]

View File

@@ -0,0 +1,35 @@
// API Base URL for klausur-service
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export interface Collection {
name: string
displayName: string
bundesland: string
useCase: string
documentCount: number
chunkCount: number
years: number[]
subjects: string[]
status: 'ready' | 'indexing' | 'empty'
}
export interface IngestionStatus {
running: boolean
lastRun: string | null
documentsIndexed: number | null
chunksCreated: number | null
errors: string[]
}
export interface SearchResult {
id: string
score: number
text: string
year: number | null
subject: string | null
niveau: string | null
taskNumber: number | null
}
// Tab definitions
export type TabId = 'collections' | 'upload' | 'ingestion' | 'search' | 'metrics'