fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,366 @@
'use client'
/**
* Dokumente Tab for edu-search page
* Shows filterable list of Abitur documents with pagination
*/
import { useState, useEffect } from 'react'
import { FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search, X, Loader2 } from 'lucide-react'
import {
AbiturDokument,
AbiturDocsResponse,
formatFileSize,
formatDocumentTitle,
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import { PDFPreviewModal } from './PDFPreviewModal'
interface DokumenteTabProps {
onDocumentCountChange?: (count: number) => void
}
export function DokumenteTab({ onDocumentCountChange }: DokumenteTabProps) {
const [documents, setDocuments] = useState<AbiturDokument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
// Filters
const [filterOpen, setFilterOpen] = useState(false)
const [filterFach, setFilterFach] = useState<string>('')
const [filterJahr, setFilterJahr] = useState<string>('')
const [filterBundesland, setFilterBundesland] = useState<string>('')
const [filterNiveau, setFilterNiveau] = useState<string>('')
const [filterTyp, setFilterTyp] = useState<string>('')
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Fetch documents
useEffect(() => {
const fetchDocuments = async () => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
params.set('page', page.toString())
params.set('limit', limit.toString())
if (filterFach) params.set('fach', filterFach)
if (filterJahr) params.set('jahr', filterJahr)
if (filterBundesland) params.set('bundesland', filterBundesland)
if (filterNiveau) params.set('niveau', filterNiveau)
if (filterTyp) params.set('typ', filterTyp)
try {
const response = await fetch(`/api/education/abitur-docs?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data: AbiturDocsResponse = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
onDocumentCountChange?.(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
fetchDocuments()
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, onDocumentCountChange])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setPage(1)
}
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp
return (
<div className="space-y-4">
{/* Filter Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setFilterOpen(!filterOpen)}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
filterOpen || hasActiveFilters
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Filter className="w-4 h-4" />
Filter
{hasActiveFilters && (
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp].filter(Boolean).length}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4" />
Filter zurücksetzen
</button>
)}
</div>
{/* Filter Dropdowns */}
{filterOpen && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
{/* Fach */}
<div>
<label className="block text-xs text-slate-500 mb-1">Fach</label>
<select
value={filterFach}
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Fächer</option>
{FAECHER.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
{/* Jahr */}
<div>
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
<select
value={filterJahr}
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Jahre</option>
{JAHRE.map(j => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
{/* Bundesland */}
<div>
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
<select
value={filterBundesland}
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Bundesländer</option>
{BUNDESLAENDER.map(b => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</div>
{/* Niveau */}
<div>
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
<select
value={filterNiveau}
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Niveaus</option>
{NIVEAUS.map(n => (
<option key={n.id} value={n.id}>{n.label}</option>
))}
</select>
</div>
{/* Typ */}
<div>
<label className="block text-xs text-slate-500 mb-1">Typ</label>
<select
value={filterTyp}
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Typen</option>
{TYPEN.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Document List */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-12 text-red-600">
<p>{error}</p>
<button
onClick={() => setPage(1)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Keine Dokumente gefunden</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Filter zurücksetzen
</button>
)}
</div>
) : (
<>
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => {
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
return (
<tr
key={doc.id}
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
onClick={() => setSelectedDocument(doc)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-red-500" />
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
{doc.dateiname}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="capitalize">{fachLabel}</span>
</td>
<td className="px-4 py-3 text-center">{doc.jahr}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.niveau === 'eA'
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700'
}`}>
{doc.niveau}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
</span>
</td>
<td className="px-4 py-3 text-right text-slate-500">
{formatFileSize(doc.file_size)}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.status === 'indexed'
? 'bg-green-100 text-green-700'
: doc.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setSelectedDocument(doc)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="Vorschau"
>
<Eye className="w-4 h-4" />
</button>
<a
href={doc.file_path}
download={doc.dateiname}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</a>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<div className="text-sm text-slate-500">
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-slate-600">
Seite {page} von {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</>
)}
</div>
{/* PDF Preview Modal */}
<PDFPreviewModal
document={selectedDocument}
onClose={() => setSelectedDocument(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
/**
* PDF Preview Modal for Abitur Documents
* Shows PDF iframe with metadata sidebar
*/
import { X, Download, ExternalLink, FileText, Calendar, BookOpen, Layers, Search } from 'lucide-react'
import { AbiturDokument, formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
interface PDFPreviewModalProps {
document: AbiturDokument | null
onClose: () => void
backendUrl?: string
}
export function PDFPreviewModal({ document, onClose, backendUrl = '' }: PDFPreviewModalProps) {
if (!document) return null
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
// Build PDF URL - try backend first, fall back to direct path
const pdfUrl = backendUrl
? `${backendUrl}/api/abitur-docs/${document.id}/file`
: document.file_path
const handleDownload = () => {
const link = window.document.createElement('a')
link.href = pdfUrl
link.download = document.dateiname
link.click()
}
const handleSearchInRAG = () => {
// Navigate to edu-search with document as context
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-2xl w-[95vw] h-[90vh] max-w-7xl flex overflow-hidden">
{/* Header */}
<div className="absolute top-0 left-0 right-0 h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 z-10">
<h2 className="font-semibold text-slate-900 truncate flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
{formatDocumentTitle(document)}
</h2>
<div className="flex items-center gap-2">
<button
onClick={handleSearchInRAG}
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
title="In RAG suchen"
>
<Search className="w-4 h-4" />
RAG-Suche
</button>
<button
onClick={handleDownload}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
title="Herunterladen"
>
<Download className="w-4 h-4" />
Download
</button>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Schliessen"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Content */}
<div className="flex w-full h-full pt-14">
{/* PDF Viewer */}
<div className="flex-1 bg-slate-100 p-4">
<iframe
src={pdfUrl}
className="w-full h-full rounded-lg border border-slate-200 bg-white"
title={document.dateiname}
/>
</div>
{/* Metadata Sidebar */}
<div className="w-80 border-l border-slate-200 bg-slate-50 p-4 overflow-y-auto">
<h3 className="font-semibold text-slate-900 mb-4">Dokument-Details</h3>
<div className="space-y-4">
{/* Fach */}
<div className="flex items-start gap-3">
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
<div className="font-medium text-slate-900">{fachLabel}</div>
</div>
</div>
{/* Jahr */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
<div className="font-medium text-slate-900">{document.jahr}</div>
</div>
</div>
{/* Niveau */}
<div className="flex items-start gap-3">
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
<div className="font-medium text-slate-900">{niveauLabel}</div>
</div>
</div>
{/* Aufgaben-Nummer */}
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
<div className="font-medium text-slate-900">
{document.aufgaben_nummer}
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
</span>
</div>
</div>
</div>
{/* Bundesland */}
<div className="flex items-start gap-3">
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
</div>
</div>
<hr className="border-slate-200" />
{/* File Info */}
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div className="flex justify-between">
<span className="text-slate-500">Dateiname</span>
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
{document.dateiname}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Groesse</span>
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Status</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${
document.status === 'indexed'
? 'bg-green-100 text-green-700'
: document.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</div>
</div>
</div>
{/* RAG Info */}
{document.indexed && document.vector_ids.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
<div className="flex items-center gap-2 text-purple-700">
<Search className="w-4 h-4" />
<span>{document.vector_ids.length} Vektoren indexiert</span>
</div>
<div className="mt-2 text-xs text-purple-600">
Confidence: {(document.confidence * 100).toFixed(0)}%
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="text-xs text-slate-400 pt-2">
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}