Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AehnlicheDokumente - RAG-based similar documents panel
|
||||
* Shows documents with similar content based on vector similarity
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
|
||||
import { FAECHER } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface AehnlicheDokumenteProps {
|
||||
documentId: string
|
||||
onSelectDocument: (doc: AbiturDokument) => void
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function AehnlicheDokumente({
|
||||
documentId,
|
||||
onSelectDocument,
|
||||
limit = 5
|
||||
}: AehnlicheDokumenteProps) {
|
||||
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarDocuments = async () => {
|
||||
if (!documentId) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
|
||||
|
||||
if (!res.ok) {
|
||||
// Use mock data if endpoint not available
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSimilarDocs(data.similar || [])
|
||||
} catch (err) {
|
||||
console.log('Similar docs fetch failed, using mock data')
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSimilarDocuments()
|
||||
}, [documentId, limit])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
// Re-trigger the effect
|
||||
setSimilarDocs([])
|
||||
setTimeout(() => {
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
|
||||
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-red-600 mb-3">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (similarDocs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarDocs.map((doc) => (
|
||||
<SimilarDocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onSelect={() => {
|
||||
// Convert SimilarDocument to AbiturDokument for selection
|
||||
// In production, this would fetch the full document
|
||||
onSelectDocument(doc as unknown as AbiturDokument)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center pt-2">
|
||||
Basierend auf semantischer Aehnlichkeit (RAG)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SimilarDocumentCard({
|
||||
document,
|
||||
onSelect
|
||||
}: {
|
||||
document: SimilarDocument
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const similarityPercent = Math.round(document.similarity_score * 100)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
group-hover:bg-blue-100 transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
|
||||
{fachLabel} {document.jahr}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span>{document.niveau}</span>
|
||||
<span>|</span>
|
||||
<span>Aufgabe {document.aufgaben_nummer}</span>
|
||||
{document.typ === 'erwartungshorizont' && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="text-orange-600">EWH</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Score */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
similarityPercent >= 80
|
||||
? 'bg-green-100 text-green-700'
|
||||
: similarityPercent >= 60
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{similarityPercent}%
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock data generator for development
|
||||
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
|
||||
// Generate consistent mock data based on document ID
|
||||
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
|
||||
const docs: SimilarDocument[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
|
||||
docs.push({
|
||||
id: `similar-${documentId}-${i}`,
|
||||
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
|
||||
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
|
||||
fach: faecher[idx % faecher.length],
|
||||
jahr: jahre[(idx + i) % jahre.length],
|
||||
niveau: niveaus[idx % niveaus.length],
|
||||
typ: typen[(idx + i) % typen.length],
|
||||
aufgaben_nummer: nummern[(idx + i) % nummern.length]
|
||||
})
|
||||
}
|
||||
|
||||
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DokumentCard - Card component for Abitur document grid view
|
||||
* Features: Preview, Download, Add to Klausur actions
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface DokumentCardProps {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
onDownload: (doc: AbiturDokument) => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
}
|
||||
|
||||
export function DokumentCard({
|
||||
document,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onAddToKlausur
|
||||
}: DokumentCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload(document)
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onAddToKlausur?.(document)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
|
||||
transition-all duration-200 cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onPreview(document)}
|
||||
>
|
||||
{/* Header with Type Badge */}
|
||||
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
|
||||
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
|
||||
|
||||
{/* Type Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
document.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Year Badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
|
||||
{document.jahr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<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>
|
||||
|
||||
{/* Hover Overlay with Preview */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<button
|
||||
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
|
||||
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview(document)
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
|
||||
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
|
||||
</h3>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{fachLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>{niveauLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="capitalize">{document.bundesland}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>{formatFileSize(document.file_size)}</span>
|
||||
<span>|</span>
|
||||
<span>{document.dateiname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
|
||||
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Zur Klausur hinzufuegen"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card variant for list view or similar documents
|
||||
*/
|
||||
export function DokumentCardCompact({
|
||||
document,
|
||||
onPreview,
|
||||
similarity_score
|
||||
}: {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
similarity_score?: number
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{fachLabel} {document.jahr} - {document.niveau}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
Aufgabe {document.aufgaben_nummer}
|
||||
{document.typ === 'erwartungshorizont' && ' (EWH)'}
|
||||
</div>
|
||||
</div>
|
||||
{similarity_score !== undefined && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{Math.round(similarity_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
|
||||
* Features: Keyboard shortcuts, zoom controls, similar documents panel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
|
||||
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
|
||||
BookOpen, Calendar, Layers, ExternalLink, Plus
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
|
||||
import { AehnlicheDokumente } from './AehnlicheDokumente'
|
||||
|
||||
interface FullscreenViewerProps {
|
||||
document: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
backendUrl?: string
|
||||
}
|
||||
|
||||
export function FullscreenViewer({
|
||||
document,
|
||||
onClose,
|
||||
onAddToKlausur,
|
||||
backendUrl = ''
|
||||
}: FullscreenViewerProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
|
||||
|
||||
// Reset state when document changes
|
||||
useEffect(() => {
|
||||
setZoom(100)
|
||||
setCurrentPage(1)
|
||||
setIsFullscreen(false)
|
||||
}, [document?.id])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
case 'F11':
|
||||
e.preventDefault()
|
||||
setIsFullscreen(prev => !prev)
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
setZoom(100)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.max(1, p - 1))
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.min(totalPages, p + 1))
|
||||
break
|
||||
case 's':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleDownload()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [document, isFullscreen, totalPages, onClose])
|
||||
|
||||
// Handle native fullscreen changes
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!window.document.fullscreenElement)
|
||||
}
|
||||
|
||||
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!document) return
|
||||
const link = window.document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = document.dateiname
|
||||
link.click()
|
||||
}, [document])
|
||||
|
||||
const handleSearchInRAG = () => {
|
||||
if (!document) return
|
||||
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
|
||||
}
|
||||
|
||||
const handleAddToKlausur = () => {
|
||||
if (!document || !onAddToKlausur) return
|
||||
onAddToKlausur(document)
|
||||
}
|
||||
|
||||
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
|
||||
const pdfUrl = backendUrl
|
||||
? `${backendUrl}/api/abitur-docs/${document.id}/file`
|
||||
: document.file_path
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
|
||||
{/* Modal Container */}
|
||||
<div className={`relative bg-white flex flex-col ${
|
||||
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{formatDocumentTitle(document)}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{document.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 w-12 text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Vergroessern (+)"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(100)}
|
||||
className="p-1.5 hover:bg-slate-200 rounded ml-1"
|
||||
title="Zuruecksetzen (0)"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<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" />
|
||||
<span className="hidden sm:inline">RAG-Suche</span>
|
||||
</button>
|
||||
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
|
||||
title="Als Vorlage verwenden"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Zur Klausur</span>
|
||||
</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 (Ctrl+S)"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Seitenleiste"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5 text-slate-600" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title="Schliessen (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
|
||||
<div
|
||||
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'top center',
|
||||
width: '100%',
|
||||
maxWidth: zoom > 100 ? 'none' : '100%'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-[calc(90vh-120px)] rounded-lg"
|
||||
title={document.dateiname}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
|
||||
{/* Sidebar Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'details'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('similar')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'similar'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Aehnliche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'details' ? (
|
||||
<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>
|
||||
|
||||
{/* Aufgabe */}
|
||||
<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>
|
||||
) : (
|
||||
<AehnlicheDokumente
|
||||
documentId={document.id}
|
||||
onSelectDocument={(doc) => {
|
||||
// This would be handled by parent - for now just show preview
|
||||
console.log('Selected similar document:', doc.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
|
||||
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemenSuche - Autocomplete search for Abitur themes
|
||||
* Features debounced API calls, suggestion display, and keyboard navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X, Loader2 } from 'lucide-react'
|
||||
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
|
||||
|
||||
interface ThemenSucheProps {
|
||||
onSearch: (query: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemenSuche({
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
|
||||
}: ThemenSucheProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Debounced API call for suggestions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length >= 2) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
setShowDropdown(true)
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
// Fallback to popular themes
|
||||
setSuggestions(POPULAR_THEMES.filter(t =>
|
||||
t.label.toLowerCase().includes(query.toLowerCase())
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelectSuggestion(suggestions[selectedIndex])
|
||||
} else if (query.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}, [showDropdown, suggestions, selectedIndex, query])
|
||||
|
||||
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
|
||||
setQuery(suggestion.label)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onSearch(suggestion.label)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim())
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
}
|
||||
setShowDropdown(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 text-slate-400">
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
bg-white shadow-sm"
|
||||
/>
|
||||
<div className="absolute right-2 flex items-center gap-2">
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
title="Suche loeschen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
|
||||
shadow-lg z-50 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Beliebte Themen
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
|
||||
transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{suggestion.label}</div>
|
||||
{suggestion.kategorie && (
|
||||
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{suggestion.count} Dokumente
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Theme Tags */}
|
||||
{!showDropdown && query.length === 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="text-sm text-slate-500">Vorschlaege:</span>
|
||||
{POPULAR_THEMES.slice(0, 5).map((theme) => (
|
||||
<button
|
||||
key={theme.aufgabentyp}
|
||||
onClick={() => handleSelectSuggestion(theme)}
|
||||
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
|
||||
hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
516
admin-lehrer/app/(admin)/education/abitur-archiv/page.tsx
Normal file
516
admin-lehrer/app/(admin)/education/abitur-archiv/page.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Abitur-Archiv - Hauptseite
|
||||
* Zentralabitur-Materialien 2021-2025 mit erweiterter Themensuche
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
|
||||
X, Loader2, Grid, List, LayoutGrid, BarChart3, Archive
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument, AbiturDocsResponse } from '@/lib/education/abitur-docs-types'
|
||||
import {
|
||||
formatFileSize,
|
||||
FAECHER,
|
||||
JAHRE,
|
||||
BUNDESLAENDER,
|
||||
NIVEAUS,
|
||||
TYPEN,
|
||||
} from '@/lib/education/abitur-docs-types'
|
||||
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { ThemenSuche } from './components/ThemenSuche'
|
||||
import { DokumentCard } from './components/DokumentCard'
|
||||
import { FullscreenViewer } from './components/FullscreenViewer'
|
||||
|
||||
export default function AbiturArchivPage() {
|
||||
// Documents state
|
||||
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
|
||||
|
||||
// View mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
|
||||
// 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>('')
|
||||
|
||||
// Theme search
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
|
||||
|
||||
// Modal
|
||||
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
|
||||
|
||||
// Fetch documents
|
||||
const fetchDocuments = useCallback(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)
|
||||
if (searchQuery) params.set('thema', searchQuery)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
|
||||
|
||||
const data = await response.json()
|
||||
setDocuments(data.documents || [])
|
||||
setTotalPages(data.total_pages || 1)
|
||||
setTotal(data.total || 0)
|
||||
setThemes(data.themes || [])
|
||||
|
||||
// Update stats
|
||||
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
|
||||
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
|
||||
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
}, [fetchDocuments])
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterFach('')
|
||||
setFilterJahr('')
|
||||
setFilterBundesland('')
|
||||
setFilterNiveau('')
|
||||
setFilterTyp('')
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDownload = (doc: AbiturDokument) => {
|
||||
const link = window.document.createElement('a')
|
||||
link.href = doc.file_path
|
||||
link.download = doc.dateiname
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (doc: AbiturDokument) => {
|
||||
// Navigate to klausur-korrektur with document reference
|
||||
const params = new URLSearchParams()
|
||||
params.set('archiv_doc_id', doc.id)
|
||||
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
|
||||
window.location.href = `/education/klausur-korrektur?${params.toString()}`
|
||||
}
|
||||
|
||||
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Archive className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Abitur-Archiv</h1>
|
||||
<p className="text-sm text-slate-500">Zentralabitur-Materialien 2021-2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-xs text-slate-500">Dokumente</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.indexed}</div>
|
||||
<div className="text-xs text-slate-500">Indexiert</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.faecher}</div>
|
||||
<div className="text-xs text-slate-500">Faecher</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
{/* Theme Search */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<ThemenSuche
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<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, searchQuery].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 zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Results count */}
|
||||
<span className="text-sm text-slate-500">
|
||||
{total} Treffer
|
||||
</span>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
title="Raster-Ansicht"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
title="Listen-Ansicht"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 Faecher</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 Bundeslaender</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>
|
||||
|
||||
{/* Active Search Query Display */}
|
||||
{searchQuery && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Search className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Suche: <strong>{searchQuery}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="ml-auto text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Display */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-16 text-red-600">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => fetchDocuments()}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-16 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 zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{documents.map((doc) => (
|
||||
<DokumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onPreview={setSelectedDocument}
|
||||
onDownload={handleDownload}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<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>
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{documents.length > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Viewer Modal */}
|
||||
<FullscreenViewer
|
||||
document={selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
319
admin-lehrer/app/(admin)/education/edu-search/page.tsx
Normal file
319
admin-lehrer/app/(admin)/education/edu-search/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Education Search Page
|
||||
* Bildungsquellen und Crawler-Verwaltung
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen, FolderOpen } from 'lucide-react'
|
||||
import { DokumenteTab } from '@/components/education/DokumenteTab'
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
type: 'api' | 'crawler' | 'manual'
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
lastUpdate?: string
|
||||
documentCount: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
const DATA_SOURCES: DataSource[] = [
|
||||
{
|
||||
id: 'nibis',
|
||||
name: 'NiBiS (Niedersachsen)',
|
||||
type: 'crawler',
|
||||
status: 'active',
|
||||
lastUpdate: '2026-01-20',
|
||||
documentCount: 1250,
|
||||
url: 'https://nibis.de',
|
||||
},
|
||||
{
|
||||
id: 'kmk',
|
||||
name: 'KMK Beschluesse',
|
||||
type: 'crawler',
|
||||
status: 'active',
|
||||
lastUpdate: '2026-01-10',
|
||||
documentCount: 450,
|
||||
url: 'https://kmk.org',
|
||||
},
|
||||
]
|
||||
|
||||
export default function EduSearchPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'documents' | 'sources' | 'crawler'>('search')
|
||||
const [documentCount, setDocumentCount] = useState<number>(0)
|
||||
|
||||
const handleDocumentCountChange = useCallback((count: number) => {
|
||||
setDocumentCount(count)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PagePurpose
|
||||
title="Education Search"
|
||||
purpose="Durchsuchen Sie Bildungsquellen und verwalten Sie Crawler fuer Lehrplaene, Erlasse und Schulinformationen. Zentraler Zugang zu bildungsrelevanten Dokumenten."
|
||||
audience={['Content Manager', 'Entwickler', 'Bildungs-Admins']}
|
||||
architecture={{
|
||||
services: ['edu-search-service (Go)', 'OpenSearch'],
|
||||
databases: ['OpenSearch (bp_documents_v1)', 'PostgreSQL'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{DATA_SOURCES.reduce((sum, s) => sum + s.documentCount, 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Dokumente gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">{DATA_SOURCES.length}</div>
|
||||
<div className="text-sm text-slate-500">Datenquellen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{DATA_SOURCES.filter(s => s.type === 'crawler').length}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Aktive Crawler</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'search'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Search className="w-4 h-4 inline mr-2" />
|
||||
Suche
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'documents'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 inline mr-2" />
|
||||
Dokumente
|
||||
{documentCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-white/20 rounded text-xs">
|
||||
{documentCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'sources'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-4 h-4 inline mr-2" />
|
||||
Datenquellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('crawler')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'crawler'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 inline mr-2" />
|
||||
Crawler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Tab */}
|
||||
{activeTab === 'search' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex gap-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Suche nach Lehrplaenen, Erlassen, Curricula..."
|
||||
className="flex-1 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
|
||||
/>
|
||||
<button className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<span className="text-sm text-slate-500 mr-2">Schnellfilter:</span>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Lehrplaene
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Erlasse
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Kerncurricula
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Abitur
|
||||
</button>
|
||||
<button className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm hover:bg-slate-200">
|
||||
Niedersachsen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<BookOpen className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>Geben Sie einen Suchbegriff ein, um Bildungsdokumente zu durchsuchen</p>
|
||||
<p className="text-sm mt-2">Die Suche durchsucht alle angebundenen Datenquellen</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Tab */}
|
||||
{activeTab === 'documents' && (
|
||||
<DokumenteTab onDocumentCountChange={handleDocumentCountChange} />
|
||||
)}
|
||||
|
||||
{/* Sources Tab */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<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">Datenquelle</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Typ</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-600">Dokumente</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Letztes Update</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DATA_SOURCES.map((source) => (
|
||||
<tr key={source.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-slate-400" />
|
||||
<div className="font-medium text-slate-900">{source.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
source.type === 'api' ? 'bg-blue-100 text-blue-700' :
|
||||
source.type === 'crawler' ? 'bg-purple-100 text-purple-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{source.type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
source.status === 'active' ? 'bg-green-100 text-green-700' :
|
||||
source.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{source.status === 'active' ? 'Aktiv' : source.status === 'error' ? 'Fehler' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{source.documentCount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{source.lastUpdate || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{source.url && (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded inline-block"
|
||||
title="Quelle oeffnen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crawler Tab */}
|
||||
{activeTab === 'crawler' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Crawler-Verwaltung</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Hier koennen Sie die Crawler fuer verschiedene Bildungsquellen steuern.
|
||||
Das System crawlt ausschliesslich oeffentliche Bildungsdokumente (Lehrplaene, Erlasse, Curricula). Keine Personendaten.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-medium">NiBiS Crawler</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Crawlt Lehrplaene und Erlasse aus Niedersachsen
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-medium">KMK Crawler</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">
|
||||
Crawlt Beschluesse der Kultusministerkonferenz
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<span>ℹ️</span>
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/zeugnisse-crawler" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Zeugnisse-Crawler</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Strukturen verwalten</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Fairness-Dashboard
|
||||
*
|
||||
* Visualizes grading consistency and identifies outliers for review.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const GRADE_LABELS: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
|
||||
const CRITERION_COLORS: Record<string, string> = {
|
||||
rechtschreibung: '#dc2626',
|
||||
grammatik: '#2563eb',
|
||||
inhalt: '#16a34a',
|
||||
struktur: '#9333ea',
|
||||
stil: '#ea580c',
|
||||
}
|
||||
|
||||
interface FairnessData {
|
||||
klausur_id: string
|
||||
students_count: number
|
||||
graded_count: number
|
||||
statistics: {
|
||||
average_grade: number
|
||||
average_raw_points: number
|
||||
min_grade: number
|
||||
max_grade: number
|
||||
spread: number
|
||||
standard_deviation: number
|
||||
}
|
||||
criteria_breakdown: Record<string, {
|
||||
average: number
|
||||
min: number
|
||||
max: number
|
||||
count: number
|
||||
}>
|
||||
outliers: Array<{
|
||||
student_id: string
|
||||
student_name: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
direction: 'above' | 'below'
|
||||
}>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
students: Array<{
|
||||
id: string
|
||||
student_name: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
criteria_scores: Record<string, { score: number }>
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FairnessDashboardPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [fairnessData, setFairnessData] = useState<FairnessData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (klausurRes.ok) {
|
||||
setKlausur(await klausurRes.json())
|
||||
}
|
||||
|
||||
const fairnessRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/fairness`)
|
||||
if (fairnessRes.ok) {
|
||||
setFairnessData(await fairnessRes.json())
|
||||
} else {
|
||||
const errData = await fairnessRes.json()
|
||||
setError(errData.detail || 'Fehler beim Laden der Fairness-Analyse')
|
||||
}
|
||||
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setError('Fehler beim Laden der Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const getGradeDistribution = () => {
|
||||
if (!klausur?.students) return []
|
||||
|
||||
const distribution: Record<number, number> = {}
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
distribution[i] = 0
|
||||
}
|
||||
|
||||
klausur.students.forEach(s => {
|
||||
if (s.grade_points >= 0 && s.grade_points <= 15) {
|
||||
distribution[s.grade_points]++
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(distribution).map(([grade, count]) => ({
|
||||
grade: parseInt(grade),
|
||||
count,
|
||||
label: GRADE_LABELS[parseInt(grade)] || grade
|
||||
}))
|
||||
}
|
||||
|
||||
const gradeDistribution = getGradeDistribution()
|
||||
const maxCount = Math.max(...gradeDistribution.map(d => d.count), 1)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 -mx-4 -mt-6 px-4 py-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}`}
|
||||
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Klausur
|
||||
</Link>
|
||||
|
||||
<div className="text-sm text-slate-500">
|
||||
{fairnessData?.graded_count || 0} von {fairnessData?.students_count || 0} Arbeiten bewertet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Fairness-Analyse</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.title || ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fairnessData && (
|
||||
<div className="space-y-6">
|
||||
{/* Top Row: Fairness Score + Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Fairness Score Gauge */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Fairness-Score</h3>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke={
|
||||
fairnessData.fairness_score >= 70 ? '#16a34a' :
|
||||
fairnessData.fairness_score >= 40 ? '#eab308' : '#dc2626'
|
||||
}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(fairnessData.fairness_score / 100) * 352} 352`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold">{fairnessData.fairness_score}</span>
|
||||
<span className="text-xs text-slate-500">von 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 text-center text-sm font-medium ${
|
||||
fairnessData.fairness_score >= 70 ? 'text-green-600' :
|
||||
fairnessData.fairness_score >= 40 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{fairnessData.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Statistik</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Durchschnitt</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.average_grade} P ({GRADE_LABELS[Math.round(fairnessData.statistics.average_grade)]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Minimum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.min_grade} P ({GRADE_LABELS[fairnessData.statistics.min_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Maximum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.max_grade} P ({GRADE_LABELS[fairnessData.statistics.max_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Spreizung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.spread} P</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Standardabweichung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.standard_deviation}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Hinweise</h3>
|
||||
{fairnessData.warnings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fairnessData.warnings.map((warning, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-slate-700">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">Keine Auffaelligkeiten erkannt</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade Distribution Histogram */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-48">
|
||||
{gradeDistribution.map(({ grade, count, label }) => (
|
||||
<div key={grade} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className={`w-full rounded-t transition-all ${
|
||||
count > 0 ? 'bg-purple-500' : 'bg-slate-200'
|
||||
}`}
|
||||
style={{ height: `${(count / maxCount) * 160}px`, minHeight: count > 0 ? '8px' : '2px' }}
|
||||
title={`${count} Arbeiten`}
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-1 transform -rotate-45 origin-top-left w-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<div className="text-xs font-medium text-slate-700 mt-1">{count}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-6">
|
||||
<span>6 (0 Punkte)</span>
|
||||
<span>1+ (15 Punkte)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria Breakdown Heatmap */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Kriterien-Vergleich</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(fairnessData.criteria_breakdown).map(([criterion, data]) => {
|
||||
const color = CRITERION_COLORS[criterion] || '#6b7280'
|
||||
const range = data.max - data.min
|
||||
|
||||
return (
|
||||
<div key={criterion} className="flex items-center gap-4">
|
||||
<div className="w-32 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm font-medium capitalize">{criterion}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="relative h-6 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full opacity-30"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.min}%`,
|
||||
width: `${range}%`
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 rounded"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.average}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right">
|
||||
<span className="text-sm font-semibold">{data.average}%</span>
|
||||
<span className="text-xs text-slate-400 ml-1">avg</span>
|
||||
</div>
|
||||
<div className="w-20 text-right text-xs text-slate-500">
|
||||
{data.min}% - {data.max}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outliers List */}
|
||||
{fairnessData.outliers.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Ausreisser ({fairnessData.outliers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{fairnessData.outliers.map((outlier) => (
|
||||
<div
|
||||
key={outlier.student_id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
outlier.direction === 'above'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${
|
||||
outlier.direction === 'above' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}>
|
||||
{outlier.direction === 'above' ? '↑' : '↓'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{outlier.student_name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{outlier.grade_points} Punkte ({GRADE_LABELS[outlier.grade_points]}) -
|
||||
Abweichung: {outlier.deviation} Punkte {outlier.direction === 'above' ? 'ueber' : 'unter'} Durchschnitt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${outlier.student_id}`}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm hover:bg-slate-50 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Pruefen
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Students Table */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Alle Arbeiten ({klausur?.students.length || 0})
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Student</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Note</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">RS</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Gram</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Inhalt</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Struktur</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Stil</th>
|
||||
<th className="text-right py-2 px-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{klausur?.students
|
||||
.sort((a, b) => b.grade_points - a.grade_points)
|
||||
.map((student) => {
|
||||
const isOutlier = fairnessData.outliers.some(o => o.student_id === student.id)
|
||||
const outlierInfo = fairnessData.outliers.find(o => o.student_id === student.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={student.id}
|
||||
className={`border-b border-slate-100 ${
|
||||
isOutlier
|
||||
? outlierInfo?.direction === 'above'
|
||||
? 'bg-green-50'
|
||||
: 'bg-red-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{student.anonym_id}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="font-bold">
|
||||
{student.grade_points} ({GRADE_LABELS[student.grade_points] || '-'})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.rechtschreibung?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.grammatik?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.inhalt?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.struktur?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.stil?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="text-purple-600 hover:text-purple-800 text-sm"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Klausur Detail Page - Student List
|
||||
*
|
||||
* Shows all student works for a specific Klausur with upload capability.
|
||||
* Allows navigation to individual correction workspaces.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Klausur, StudentWork } from '../types'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
UPLOADED: { color: 'text-gray-600', label: 'Hochgeladen', bg: 'bg-gray-100' },
|
||||
OCR_PROCESSING: { color: 'text-yellow-600', label: 'OCR laeuft', bg: 'bg-yellow-100' },
|
||||
OCR_COMPLETE: { color: 'text-blue-600', label: 'OCR fertig', bg: 'bg-blue-100' },
|
||||
ANALYZING: { color: 'text-purple-600', label: 'Analyse', bg: 'bg-purple-100' },
|
||||
FIRST_EXAMINER: { color: 'text-orange-600', label: 'Erstkorrektur', bg: 'bg-orange-100' },
|
||||
SECOND_EXAMINER: { color: 'text-cyan-600', label: 'Zweitkorrektur', bg: 'bg-cyan-100' },
|
||||
COMPLETED: { color: 'text-green-600', label: 'Fertig', bg: 'bg-green-100' },
|
||||
ERROR: { color: 'text-red-600', label: 'Fehler', bg: 'bg-red-100' },
|
||||
}
|
||||
|
||||
export default function KlausurDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchKlausur = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setKlausur(data)
|
||||
} else if (res.status === 404) {
|
||||
setError('Klausur nicht gefunden')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch klausur:', err)
|
||||
setError('Verbindung fehlgeschlagen')
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
const fetchStudents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStudents(Array.isArray(data) ? data : data.students || [])
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch students:', err)
|
||||
setError('Fehler beim Laden der Arbeiten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchKlausur()
|
||||
fetchStudents()
|
||||
}, [fetchKlausur, fetchStudents])
|
||||
|
||||
const exportOverviewPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/overview`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Notenuebersicht_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export overview PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAllGutachtenPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/all-gutachten`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Alle_Gutachten_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export all gutachten PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
setError(null)
|
||||
|
||||
const totalFiles = files.length
|
||||
let uploadedCount = 0
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
console.error(`Failed to upload ${file.name}:`, errorData)
|
||||
}
|
||||
|
||||
uploadedCount++
|
||||
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100))
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload ${file.name}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
fetchStudents()
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteStudent = async (studentId: string) => {
|
||||
if (!confirm('Studentenarbeit wirklich loeschen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStudents(prev => prev.filter(s => s.id !== studentId))
|
||||
} else {
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete student:', err)
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
const getGradeDisplay = (student: StudentWork) => {
|
||||
if (student.grade_points === undefined || student.grade_points === null) {
|
||||
return { points: '-', label: '-' }
|
||||
}
|
||||
const labels: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
return {
|
||||
points: student.grade_points.toString(),
|
||||
label: labels[student.grade_points] || '-'
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: students.length,
|
||||
completed: students.filter(s => s.status === 'COMPLETED').length,
|
||||
inProgress: students.filter(s => ['FIRST_EXAMINER', 'SECOND_EXAMINER', 'ANALYZING'].includes(s.status)).length,
|
||||
pending: students.filter(s => ['UPLOADED', 'OCR_PROCESSING', 'OCR_COMPLETE'].includes(s.status)).length,
|
||||
avgGrade: students.filter(s => s.grade_points !== undefined && s.grade_points !== null)
|
||||
.reduce((sum, s, _, arr) => sum + (s.grade_points || 0) / arr.length, 0).toFixed(1),
|
||||
}
|
||||
|
||||
if (loading && !klausur) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/education/klausur-korrektur"
|
||||
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">{klausur?.title || 'Klausur'}</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.subject} - {klausur?.year} | {students.length} Arbeiten</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-800">{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto text-red-600 hover:text-red-800">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-sm text-slate-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
<div className="text-sm text-slate-500">Fertig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.inProgress}</div>
|
||||
<div className="text-sm text-slate-500">In Arbeit</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
|
||||
<div className="text-sm text-slate-500">Ausstehend</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.avgGrade}</div>
|
||||
<div className="text-sm text-slate-500">Durchschnitt Note</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fairness Analysis Button */}
|
||||
{stats.completed >= 2 && (
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all shadow-sm"
|
||||
>
|
||||
<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>
|
||||
Fairness-Analyse oeffnen
|
||||
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{stats.completed} bewertet
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={exportOverviewPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
{exporting ? 'Exportiere...' : 'Notenuebersicht PDF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={exportAllGutachtenPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
{exporting ? 'Exportiere...' : 'Alle Gutachten PDF'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten hochladen</h2>
|
||||
<p className="text-sm text-slate-500">PDF oder Bilder (JPG, PNG) der gescannten Arbeiten</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 cursor-pointer ${
|
||||
uploading
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{uploadProgress}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Students List */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten ({students.length})</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<svg className="mx-auto h-12 w-12 text-slate-300 mb-3" 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>
|
||||
<p>Noch keine Arbeiten hochgeladen</p>
|
||||
<p className="text-sm">Laden Sie gescannte PDFs oder Bilder hoch</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200">
|
||||
{students.map((student, index) => {
|
||||
const grade = getGradeDisplay(student)
|
||||
const status = statusConfig[student.status] || statusConfig.UPLOADED
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
className="p-4 hover:bg-slate-50 flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{student.anonym_id || `Arbeit ${index + 1}`}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center w-20">
|
||||
<div className="text-lg font-bold text-slate-800">{grade.points}</div>
|
||||
<div className="text-xs text-slate-500">{grade.label}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-24">
|
||||
{student.criteria_scores && Object.keys(student.criteria_scores).length > 0 ? (
|
||||
<div className="flex gap-1">
|
||||
{['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].map(criterion => (
|
||||
<div
|
||||
key={criterion}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
student.criteria_scores[criterion] !== undefined
|
||||
? 'bg-green-500'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
title={criterion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400">Keine Bewertung</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Korrigieren
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteStudent(student.id)}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fairness Check Button */}
|
||||
{students.filter(s => s.status === 'COMPLETED').length >= 3 && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-800">Fairness-Check verfuegbar</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
Pruefen Sie die Bewertungen auf Konsistenz und Fairness
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Fairness-Check starten
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationLayer
|
||||
*
|
||||
* SVG overlay component for displaying and creating annotations on documents.
|
||||
* Renders positioned rectangles with color-coding by annotation type.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (annotation: Annotation) => void
|
||||
selectedAnnotationId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AnnotationLayer({
|
||||
annotations,
|
||||
selectedTool,
|
||||
onCreateAnnotation,
|
||||
onSelectAnnotation,
|
||||
selectedAnnotationId,
|
||||
disabled = false,
|
||||
}: AnnotationLayerProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
|
||||
|
||||
// Convert mouse position to percentage
|
||||
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return null
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
|
||||
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
|
||||
}, [])
|
||||
|
||||
// Handle mouse down - start drawing
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled || !selectedTool) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
setIsDrawing(true)
|
||||
setStartPos(pos)
|
||||
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
|
||||
},
|
||||
[disabled, selectedTool, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse move - update rectangle
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDrawing || !startPos) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
const x = Math.min(startPos.x, pos.x)
|
||||
const y = Math.min(startPos.y, pos.y)
|
||||
const width = Math.abs(pos.x - startPos.x)
|
||||
const height = Math.abs(pos.y - startPos.y)
|
||||
|
||||
setCurrentRect({ x, y, width, height })
|
||||
},
|
||||
[isDrawing, startPos, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse up - finish drawing
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDrawing || !currentRect || !selectedTool) {
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
|
||||
if (currentRect.width > 1 && currentRect.height > 0.5) {
|
||||
onCreateAnnotation(currentRect, selectedTool)
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
|
||||
|
||||
// Handle clicking on existing annotation
|
||||
const handleAnnotationClick = useCallback(
|
||||
(e: React.MouseEvent, annotation: Annotation) => {
|
||||
e.stopPropagation()
|
||||
onSelectAnnotation(annotation)
|
||||
},
|
||||
[onSelectAnnotation]
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={`absolute inset-0 w-full h-full ${
|
||||
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
|
||||
}`}
|
||||
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* SVG Defs for patterns */}
|
||||
<defs>
|
||||
{/* Wavy pattern for Rechtschreibung errors */}
|
||||
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
|
||||
<path
|
||||
d="M0 2 Q 2.5 0, 5 2 T 10 2"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</pattern>
|
||||
{/* Straight underline pattern for Grammatik errors */}
|
||||
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
|
||||
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Existing annotations */}
|
||||
{annotations.map((annotation) => {
|
||||
const isSelected = annotation.id === selectedAnnotationId
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
const isRS = annotation.type === 'rechtschreibung'
|
||||
const isGram = annotation.type === 'grammatik'
|
||||
|
||||
return (
|
||||
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
|
||||
{/* Background rectangle - different styles for RS/Gram */}
|
||||
{isRS || isGram ? (
|
||||
<>
|
||||
{/* Light highlight background */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={isSelected ? 0.25 : 0.15}
|
||||
className="cursor-pointer hover:fill-opacity-25 transition-all"
|
||||
/>
|
||||
{/* Underline - wavy for RS, straight for Gram */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height="0.5%"
|
||||
fill={isRS ? 'url(#wavyPattern)' : color}
|
||||
stroke="none"
|
||||
/>
|
||||
{/* Border when selected */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard rectangle for other annotation types */
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={0.2}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
|
||||
className="cursor-pointer hover:fill-opacity-30 transition-all"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type indicator icon (small circle in corner) */}
|
||||
<circle
|
||||
cx={`${annotation.position.x}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Type letter */}
|
||||
<text
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{annotation.type.charAt(0).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Severity indicator (small dot) */}
|
||||
{annotation.severity === 'critical' && (
|
||||
<circle
|
||||
cx={`${annotation.position.x + annotation.position.width}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="4"
|
||||
fill="#dc2626"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* Corner handles */}
|
||||
{[
|
||||
{ cx: annotation.position.x, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
|
||||
{
|
||||
cx: annotation.position.x + annotation.position.width,
|
||||
cy: annotation.position.y + annotation.position.height,
|
||||
},
|
||||
].map((corner, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={`${corner.cx}%`}
|
||||
cy={`${corner.cy}%`}
|
||||
r="4"
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Currently drawing rectangle */}
|
||||
{currentRect && selectedTool && (
|
||||
<rect
|
||||
x={`${currentRect.x}%`}
|
||||
y={`${currentRect.y}%`}
|
||||
width={`${currentRect.width}%`}
|
||||
height={`${currentRect.height}%`}
|
||||
fill={ANNOTATION_COLORS[selectedTool]}
|
||||
fillOpacity={0.3}
|
||||
stroke={ANNOTATION_COLORS[selectedTool]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5,5"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationPanel
|
||||
*
|
||||
* Panel for viewing, editing, and managing annotations.
|
||||
* Shows a list of all annotations with options to edit text, change severity, or delete.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Annotation, AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationPanelProps {
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: Annotation | null
|
||||
onSelectAnnotation: (annotation: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
}
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'minor', label: 'Leicht', color: '#fbbf24' },
|
||||
{ value: 'major', label: 'Mittel', color: '#f97316' },
|
||||
{ value: 'critical', label: 'Schwer', color: '#dc2626' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: 'Rechtschreibung',
|
||||
grammatik: 'Grammatik',
|
||||
inhalt: 'Inhalt',
|
||||
struktur: 'Struktur',
|
||||
stil: 'Stil',
|
||||
comment: 'Kommentar',
|
||||
highlight: 'Markierung',
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
selectedAnnotation,
|
||||
onSelectAnnotation,
|
||||
onUpdateAnnotation,
|
||||
onDeleteAnnotation,
|
||||
}: AnnotationPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const [editSuggestion, setEditSuggestion] = useState('')
|
||||
|
||||
// Group annotations by type
|
||||
const groupedAnnotations = annotations.reduce(
|
||||
(acc, ann) => {
|
||||
if (!acc[ann.type]) {
|
||||
acc[ann.type] = []
|
||||
}
|
||||
acc[ann.type].push(ann)
|
||||
return acc
|
||||
},
|
||||
{} as Record<AnnotationType, Annotation[]>
|
||||
)
|
||||
|
||||
const handleEdit = (annotation: Annotation) => {
|
||||
setEditingId(annotation.id)
|
||||
setEditText(annotation.text)
|
||||
setEditSuggestion(annotation.suggestion || '')
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateAnnotation(id, { text: editText, suggestion: editSuggestion || undefined })
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Keine Annotationen vorhanden</p>
|
||||
<p className="text-xs mt-1">Waehlen Sie ein Werkzeug und markieren Sie Stellen im Dokument</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Summary */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">{annotations.length} Annotationen</span>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(groupedAnnotations).map(([type, anns]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-2 py-0.5 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type as AnnotationType] }}
|
||||
>
|
||||
{anns.length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations list by type */}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(Object.entries(groupedAnnotations) as [AnnotationType, Annotation[]][]).map(([type, anns]) => (
|
||||
<div key={type}>
|
||||
{/* Type header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type] }}
|
||||
>
|
||||
{TYPE_LABELS[type]} ({anns.length})
|
||||
</div>
|
||||
|
||||
{/* Annotations in this type */}
|
||||
{anns.map((annotation) => {
|
||||
const isSelected = selectedAnnotation?.id === annotation.id
|
||||
const isEditing = editingId === annotation.id
|
||||
const severityInfo = SEVERITY_OPTIONS.find((s) => s.value === annotation.severity)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className={`p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => onSelectAnnotation(isSelected ? null : annotation)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
placeholder="Kommentar..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded resize-none focus:ring-2 focus:ring-purple-500"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{(type === 'rechtschreibung' || type === 'grammatik') && (
|
||||
<input
|
||||
type="text"
|
||||
value={editSuggestion}
|
||||
onChange={(e) => setEditSuggestion(e.target.value)}
|
||||
placeholder="Korrekturvorschlag..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSaveEdit(annotation.id)}
|
||||
className="flex-1 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 py-1 text-xs bg-slate-200 text-slate-700 rounded hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View mode */
|
||||
<>
|
||||
{/* Severity badge */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{ backgroundColor: severityInfo?.color || '#6b7280' }}
|
||||
>
|
||||
{severityInfo?.label || 'Unbekannt'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Seite {annotation.page}</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{annotation.text && <p className="text-sm text-slate-700 mb-1">{annotation.text}</p>}
|
||||
|
||||
{/* Suggestion */}
|
||||
{annotation.suggestion && (
|
||||
<p className="text-xs text-green-700 bg-green-50 px-2 py-1 rounded mb-1">
|
||||
<span className="font-medium">Korrektur:</span> {annotation.suggestion}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions (only when selected) */}
|
||||
{isSelected && (
|
||||
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(annotation)
|
||||
}}
|
||||
className="flex-1 py-1 text-xs bg-slate-100 text-slate-700 rounded hover:bg-slate-200"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
{/* Severity buttons */}
|
||||
<div className="flex gap-1">
|
||||
{SEVERITY_OPTIONS.map((sev) => (
|
||||
<button
|
||||
key={sev.value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUpdateAnnotation(annotation.id, { severity: sev.value })
|
||||
}}
|
||||
className={`w-6 h-6 rounded text-xs text-white font-bold ${
|
||||
annotation.severity === sev.value ? 'ring-2 ring-offset-1 ring-slate-400' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: sev.color }}
|
||||
title={sev.label}
|
||||
>
|
||||
{sev.label[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Annotation loeschen?')) {
|
||||
onDeleteAnnotation(annotation.id)
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationToolbar
|
||||
*
|
||||
* Toolbar for selecting annotation tools and controlling the document viewer.
|
||||
*/
|
||||
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationToolbarProps {
|
||||
selectedTool: AnnotationType | null
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ANNOTATION_TOOLS: { type: AnnotationType; label: string; shortcut: string }[] = [
|
||||
{ type: 'rechtschreibung', label: 'Rechtschreibung', shortcut: 'R' },
|
||||
{ type: 'grammatik', label: 'Grammatik', shortcut: 'G' },
|
||||
{ type: 'inhalt', label: 'Inhalt', shortcut: 'I' },
|
||||
{ type: 'struktur', label: 'Struktur', shortcut: 'S' },
|
||||
{ type: 'stil', label: 'Stil', shortcut: 'T' },
|
||||
{ type: 'comment', label: 'Kommentar', shortcut: 'K' },
|
||||
]
|
||||
|
||||
export default function AnnotationToolbar({
|
||||
selectedTool,
|
||||
onSelectTool,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
annotationCounts,
|
||||
disabled = false,
|
||||
}: AnnotationToolbarProps) {
|
||||
const handleToolClick = (type: AnnotationType) => {
|
||||
if (disabled) return
|
||||
onSelectTool(selectedTool === type ? null : type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
{/* Annotation tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-slate-500 mr-2">Markieren:</span>
|
||||
{ANNOTATION_TOOLS.map(({ type, label, shortcut }) => {
|
||||
const isSelected = selectedTool === type
|
||||
const count = annotationCounts[type] || 0
|
||||
const color = ANNOTATION_COLORS[type]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleToolClick(type)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative px-2 py-1.5 text-xs rounded border-2 transition-all
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
|
||||
${isSelected ? 'ring-2 ring-offset-1 ring-slate-400' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderColor: color,
|
||||
color: isSelected ? 'white' : color,
|
||||
backgroundColor: isSelected ? color : 'transparent',
|
||||
}}
|
||||
title={`${label} (${shortcut})`}
|
||||
>
|
||||
<span className="font-medium">{shortcut}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-2 -right-2 w-4 h-4 text-[10px] rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Clear selection button */}
|
||||
{selectedTool && (
|
||||
<button
|
||||
onClick={() => onSelectTool(null)}
|
||||
className="ml-2 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Mode indicator */}
|
||||
{selectedTool && (
|
||||
<div
|
||||
className="px-3 py-1 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[selectedTool] }}
|
||||
>
|
||||
{ANNOTATION_TOOLS.find((t) => t.type === selectedTool)?.label || selectedTool}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.max(50, zoom - 10))}
|
||||
disabled={zoom <= 50}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.min(200, zoom + 10))}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onZoomChange(100)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-slate-200"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EHSuggestionPanel
|
||||
*
|
||||
* Panel for displaying Erwartungshorizont-based suggestions.
|
||||
* Uses RAG to find relevant passages from the linked EH.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface EHSuggestion {
|
||||
id: string
|
||||
eh_id: string
|
||||
eh_title: string
|
||||
text: string
|
||||
score: number
|
||||
criterion: string
|
||||
source_chunk_index: number
|
||||
decrypted: boolean
|
||||
}
|
||||
|
||||
interface EHSuggestionPanelProps {
|
||||
studentId: string
|
||||
klausurId: string
|
||||
hasEH: boolean
|
||||
apiBase: string
|
||||
onInsertSuggestion?: (text: string, criterion: string) => void
|
||||
}
|
||||
|
||||
const CRITERIA = [
|
||||
{ id: 'allgemein', label: 'Alle Kriterien' },
|
||||
{ id: 'inhalt', label: 'Inhalt', color: '#16a34a' },
|
||||
{ id: 'struktur', label: 'Struktur', color: '#9333ea' },
|
||||
{ id: 'stil', label: 'Stil', color: '#ea580c' },
|
||||
]
|
||||
|
||||
export default function EHSuggestionPanel({
|
||||
studentId,
|
||||
klausurId,
|
||||
hasEH,
|
||||
apiBase,
|
||||
onInsertSuggestion,
|
||||
}: EHSuggestionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [selectedCriterion, setSelectedCriterion] = useState<string>('allgemein')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [needsPassphrase, setNeedsPassphrase] = useState(false)
|
||||
const [queryPreview, setQueryPreview] = useState<string | null>(null)
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/students/${studentId}/eh-suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
criterion: selectedCriterion === 'allgemein' ? null : selectedCriterion,
|
||||
passphrase: passphrase || null,
|
||||
limit: 5,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.detail || 'Fehler beim Laden der Vorschlaege')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.needs_passphrase) {
|
||||
setNeedsPassphrase(true)
|
||||
setSuggestions([])
|
||||
setError(data.message)
|
||||
} else {
|
||||
setNeedsPassphrase(false)
|
||||
setSuggestions(data.suggestions || [])
|
||||
setQueryPreview(data.query_preview || null)
|
||||
|
||||
if (data.suggestions?.length === 0) {
|
||||
setError(data.message || 'Keine passenden Vorschlaege gefunden')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch EH suggestions:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase, studentId, selectedCriterion, passphrase])
|
||||
|
||||
const handleInsert = (suggestion: EHSuggestion) => {
|
||||
if (onInsertSuggestion) {
|
||||
onInsertSuggestion(suggestion.text, suggestion.criterion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEH) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<div className="text-slate-400 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
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>
|
||||
<p className="text-sm">Kein Erwartungshorizont verknuepft</p>
|
||||
<p className="text-xs mt-1">Laden Sie einen EH in der RAG-Verwaltung hoch</p>
|
||||
</div>
|
||||
<a
|
||||
href="/ai/rag"
|
||||
className="inline-block px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zur RAG-Verwaltung
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Criterion selector */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{CRITERIA.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedCriterion(c.id)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCriterion === c.id
|
||||
? 'text-white'
|
||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
style={
|
||||
selectedCriterion === c.id
|
||||
? { backgroundColor: c.color || '#6366f1' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passphrase input (if needed) */}
|
||||
{needsPassphrase && (
|
||||
<div className="p-3 bg-yellow-50 border-b border-yellow-200">
|
||||
<label className="block text-xs font-medium text-yellow-800 mb-1">
|
||||
EH-Passphrase (verschluesselt)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Passphrase eingeben..."
|
||||
className="flex-1 px-2 py-1 text-sm border border-yellow-300 rounded focus:ring-2 focus:ring-yellow-500"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={!passphrase}
|
||||
className="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
Laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fetch button */}
|
||||
<div className="p-3 border-b border-slate-200">
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Lade Vorschlaege...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
EH-Vorschlaege laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Query preview */}
|
||||
{queryPreview && (
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200">
|
||||
<div className="text-xs text-slate-500 mb-1">Basierend auf:</div>
|
||||
<div className="text-xs text-slate-700 italic truncate">"{queryPreview}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && !needsPassphrase && (
|
||||
<div className="p-3 bg-red-50 border-b border-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions list */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{suggestions.length === 0 && !loading && !error && (
|
||||
<div className="p-4 text-center text-slate-400 text-sm">
|
||||
Klicken Sie auf "EH-Vorschlaege laden" um passende Stellen aus dem Erwartungshorizont zu
|
||||
finden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">#{idx + 1}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ANNOTATION_COLORS[suggestion.criterion as AnnotationType] || '#6366f1',
|
||||
}}
|
||||
>
|
||||
{suggestion.criterion}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Relevanz: {Math.round(suggestion.score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{!suggestion.decrypted && (
|
||||
<span className="text-[10px] text-yellow-600">Verschluesselt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-slate-700 mb-2 line-clamp-4">{suggestion.text}</p>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Quelle: {suggestion.eh_title}</span>
|
||||
{onInsertSuggestion && suggestion.decrypted && (
|
||||
<button
|
||||
onClick={() => handleInsert(suggestion)}
|
||||
className="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
|
||||
>
|
||||
Im Gutachten verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AnnotationLayer } from './AnnotationLayer'
|
||||
export { default as AnnotationPanel } from './AnnotationPanel'
|
||||
export { default as AnnotationToolbar } from './AnnotationToolbar'
|
||||
export { default as EHSuggestionPanel } from './EHSuggestionPanel'
|
||||
1121
admin-lehrer/app/(admin)/education/klausur-korrektur/page.tsx
Normal file
1121
admin-lehrer/app/(admin)/education/klausur-korrektur/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
195
admin-lehrer/app/(admin)/education/klausur-korrektur/types.ts
Normal file
195
admin-lehrer/app/(admin)/education/klausur-korrektur/types.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
82
admin-lehrer/app/(admin)/education/page.tsx
Normal file
82
admin-lehrer/app/(admin)/education/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { getCategoryById } from '@/lib/navigation'
|
||||
import { ModuleCard } from '@/components/common/ModuleCard'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
export default function EducationPage() {
|
||||
const category = getCategoryById('education')
|
||||
|
||||
if (!category) {
|
||||
return <div>Kategorie nicht gefunden</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title={category.name}
|
||||
purpose="Diese Kategorie umfasst Module fuer Bildungsdokumente. Hier verwalten Sie Crawler fuer Lehrplaene, Erlasse und amtliche Bildungsquellen."
|
||||
audience={['Content Manager', 'Entwickler']}
|
||||
architecture={{
|
||||
services: ['edu-search-service (Go)'],
|
||||
databases: ['PostgreSQL', 'OpenSearch'],
|
||||
}}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">2</div>
|
||||
<div className="text-sm text-slate-500">Aktive Crawler</div>
|
||||
<div className="text-xs text-slate-400">NiBiS, KMK</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
<div className="text-xs text-slate-400">Geplant</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">0</div>
|
||||
<div className="text-sm text-slate-500">Personendaten</div>
|
||||
<div className="text-xs text-green-500">Datenschutz-konform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Module</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{category.modules.map((module) => (
|
||||
<ModuleCard key={module.id} module={module} category={category} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<span>📚</span>
|
||||
Bildungsdokumente
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Das System crawlt ausschliesslich oeffentliche Bildungsdokumente (Lehrplaene, Erlasse, Beschluesse).
|
||||
<strong> Keine personenbezogenen Daten</strong> werden erfasst oder gespeichert.
|
||||
Alle Crawler respektieren robots.txt und verwenden Rate-Limiting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compliance Note */}
|
||||
<div className="mt-4 bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-green-800 flex items-center gap-2">
|
||||
<span>✅</span>
|
||||
Datenschutz-Hinweis
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Dieses Modul verarbeitet <strong>keine personenbezogenen Daten</strong>.
|
||||
Es werden ausschliesslich amtliche Dokumente und Metadaten aus oeffentlichen Quellen indexiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
admin-lehrer/app/(admin)/education/zeugnisse-crawler/page.tsx
Normal file
181
admin-lehrer/app/(admin)/education/zeugnisse-crawler/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Zeugnisse-Crawler Page
|
||||
* Verwaltet Zeugnis-Strukturen und -Vorlagen
|
||||
*/
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { FileText, Upload, Settings, Database, RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function ZeugnisseCrawlerPage() {
|
||||
const moduleInfo = getModuleByHref('/education/zeugnisse-crawler')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">48</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Vorlagen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">12</div>
|
||||
<div className="text-sm text-slate-500">Schulformen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">156</div>
|
||||
<div className="text-sm text-slate-500">Felder erkannt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Zeugnis-Strukturen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Upload Card */}
|
||||
<div className="border border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 hover:bg-blue-50/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-10 h-10 mx-auto mb-3 text-slate-400" />
|
||||
<div className="font-medium text-slate-700">Zeugnis hochladen</div>
|
||||
<div className="text-sm text-slate-500 mt-1">PDF oder Bild</div>
|
||||
</div>
|
||||
|
||||
{/* Niedersachsen */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Niedersachsen</div>
|
||||
<div className="text-xs text-slate-500">12 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">IGS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bayern */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Bayern</div>
|
||||
<div className="text-xs text-slate-500">10 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Realschule</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NRW */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Nordrhein-Westfalen</div>
|
||||
<div className="text-xs text-slate-500">14 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gesamtschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baden-Württemberg */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Baden-Wuerttemberg</div>
|
||||
<div className="text-xs text-slate-500">8 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weitere */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow bg-slate-50">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Database className="w-8 h-8 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-700">Weitere Bundeslaender</div>
|
||||
<div className="text-xs text-slate-500">4 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Hessen, Sachsen, Berlin, Hamburg...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawler Section */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Crawler-Status
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Schulportal NI</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 2 Stunden</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">KMK Vorlagen</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 1 Tag</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/edu-search" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Education Search</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente durchsuchen</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Dokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user