[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)

Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:28:57 +02:00
parent 9ba420fa91
commit b681ddb131
251 changed files with 30016 additions and 25037 deletions

View File

@@ -0,0 +1,214 @@
import {
FileText, ChevronLeft, ChevronRight, Eye, Download, Loader2,
} from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import { formatFileSize, FAECHER } from '@/lib/education/abitur-docs-types'
import { DokumentCard } from '../components/DokumentCard'
interface DocumentDisplayProps {
documents: AbiturDokument[]
loading: boolean
error: string | null
viewMode: 'grid' | 'list'
hasActiveFilters: boolean
onClearFilters: () => void
onSelectDocument: (doc: AbiturDokument) => void
onDownload: (doc: AbiturDokument) => void
onAddToKlausur: (doc: AbiturDokument) => void
onRetry: () => void
// Pagination
page: number
totalPages: number
total: number
limit: number
onPageChange: (page: number) => void
}
export function DocumentDisplay({
documents, loading, error, viewMode, hasActiveFilters,
onClearFilters, onSelectDocument, onDownload, onAddToKlausur, onRetry,
page, totalPages, total, limit, onPageChange,
}: DocumentDisplayProps) {
return (
<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={onRetry}
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={onClearFilters}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Filter zuruecksetzen
</button>
)}
</div>
) : viewMode === 'grid' ? (
<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={onSelectDocument}
onDownload={onDownload}
onAddToKlausur={onAddToKlausur}
/>
))}
</div>
</div>
) : (
<ListView
documents={documents}
onSelectDocument={onSelectDocument}
onDownload={onDownload}
/>
)}
{/* 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={() => onPageChange(Math.max(1, page - 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={() => onPageChange(Math.min(totalPages, page + 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>
)
}
function ListView({
documents,
onSelectDocument,
onDownload,
}: {
documents: AbiturDokument[]
onSelectDocument: (doc: AbiturDokument) => void
onDownload: (doc: AbiturDokument) => void
}) {
return (
<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={() => onSelectDocument(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={() => onSelectDocument(doc)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="Vorschau"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => onDownload(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>
)
}

View File

@@ -0,0 +1,174 @@
import {
Filter, X, LayoutGrid, List,
} from 'lucide-react'
import {
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import type { ViewMode } from '@/lib/education/abitur-archiv-types'
interface FilterBarProps {
filterOpen: boolean
onToggleFilter: () => void
hasActiveFilters: boolean
onClearFilters: () => void
total: number
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
filterFach: string
filterJahr: string
filterBundesland: string
filterNiveau: string
filterTyp: string
onFachChange: (v: string) => void
onJahrChange: (v: string) => void
onBundeslandChange: (v: string) => void
onNiveauChange: (v: string) => void
onTypChange: (v: string) => void
onResetPage: () => void
searchQuery: string
}
export function FilterBar({
filterOpen, onToggleFilter, hasActiveFilters, onClearFilters,
total, viewMode, onViewModeChange,
filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp,
onFachChange, onJahrChange, onBundeslandChange, onNiveauChange, onTypChange,
onResetPage, searchQuery,
}: FilterBarProps) {
const activeCount = [filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery].filter(Boolean).length
return (
<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={onToggleFilter}
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">
{activeCount}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={onClearFilters}
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">
<span className="text-sm text-slate-500">{total} Treffer</span>
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => onViewModeChange('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={() => onViewModeChange('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">
<div>
<label className="block text-xs text-slate-500 mb-1">Fach</label>
<select
value={filterFach}
onChange={(e) => { onFachChange(e.target.value); onResetPage() }}
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>
<div>
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
<select
value={filterJahr}
onChange={(e) => { onJahrChange(e.target.value); onResetPage() }}
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>
<div>
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
<select
value={filterBundesland}
onChange={(e) => { onBundeslandChange(e.target.value); onResetPage() }}
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>
<div>
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
<select
value={filterNiveau}
onChange={(e) => { onNiveauChange(e.target.value); onResetPage() }}
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>
<div>
<label className="block text-xs text-slate-500 mb-1">Typ</label>
<select
value={filterTyp}
onChange={(e) => { onTypChange(e.target.value); onResetPage() }}
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>
)
}

View File

@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback } from 'react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
export function useAbiturArchiv() {
// 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 })
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 || [])
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) => {
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 {
documents,
loading,
error,
page,
setPage,
totalPages,
total,
limit,
viewMode,
setViewMode,
filterOpen,
setFilterOpen,
filterFach,
setFilterFach,
filterJahr,
setFilterJahr,
filterBundesland,
setFilterBundesland,
filterNiveau,
setFilterNiveau,
filterTyp,
setFilterTyp,
searchQuery,
selectedDocument,
setSelectedDocument,
stats,
clearFilters,
handleSearch,
handleClearSearch,
handleDownload,
handleAddToKlausur,
hasActiveFilters,
fetchDocuments,
}
}

View File

@@ -5,134 +5,49 @@
* 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 { Search, X, Archive } from 'lucide-react'
import { ThemenSuche } from './components/ThemenSuche'
import { DokumentCard } from './components/DokumentCard'
import { FullscreenViewer } from './components/FullscreenViewer'
import { useAbiturArchiv } from './_components/useAbiturArchiv'
import { FilterBar } from './_components/FilterBar'
import { DocumentDisplay } from './_components/DocumentDisplay'
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
const {
documents,
loading,
error,
page,
setPage,
totalPages,
total,
limit,
viewMode,
setViewMode,
filterOpen,
setFilterOpen,
filterFach,
setFilterFach,
filterJahr,
setFilterJahr,
filterBundesland,
setFilterBundesland,
filterNiveau,
setFilterNiveau,
filterTyp,
setFilterTyp,
searchQuery,
selectedDocument,
setSelectedDocument,
stats,
clearFilters,
handleSearch,
handleClearSearch,
handleDownload,
handleAddToKlausur,
hasActiveFilters,
fetchDocuments,
} = useAbiturArchiv()
return (
<div className="min-h-screen bg-slate-50">
@@ -173,154 +88,30 @@ export default function AbiturArchivPage() {
<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}
/>
<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>
<FilterBar
filterOpen={filterOpen}
onToggleFilter={() => setFilterOpen(!filterOpen)}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
total={total}
viewMode={viewMode}
onViewModeChange={setViewMode}
filterFach={filterFach}
filterJahr={filterJahr}
filterBundesland={filterBundesland}
filterNiveau={filterNiveau}
filterTyp={filterTyp}
onFachChange={setFilterFach}
onJahrChange={setFilterJahr}
onBundeslandChange={setFilterBundesland}
onNiveauChange={setFilterNiveau}
onTypChange={setFilterTyp}
onResetPage={() => setPage(1)}
searchQuery={searchQuery}
/>
{/* Active Search Query Display */}
{searchQuery && (
@@ -338,171 +129,23 @@ export default function AbiturArchivPage() {
</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>
<DocumentDisplay
documents={documents}
loading={loading}
error={error}
viewMode={viewMode}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
onSelectDocument={setSelectedDocument}
onDownload={handleDownload}
onAddToKlausur={handleAddToKlausur}
onRetry={fetchDocuments}
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={setPage}
/>
</div>
{/* Fullscreen Viewer Modal */}

View File

@@ -0,0 +1,252 @@
'use client'
/**
* Right panel (1/3 width): Tabs for Kriterien, Annotationen, Gutachten, EH-Vorschlaege.
*/
import { AnnotationPanel, EHSuggestionPanel } from '../../../components'
import type {
Klausur,
Annotation,
AnnotationType,
GradeInfo,
CriteriaScores,
} from '../../../types'
import type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
import { API_BASE } from './workspace-types'
import { CriteriaTab } from './CriteriaTab'
interface CorrectionPanelProps {
// Data
klausur: Klausur | null
klausurId: string
studentId: string
annotations: Annotation[]
gradeInfo: GradeInfo | null
criteriaScores: CriteriaScores
gutachten: string
workflow: ExaminerWorkflow | null
totals: GradeTotals
selectedAnnotation: Annotation | null
// UI flags
activeTab: ActiveTab
generatingGutachten: boolean
saving: boolean
exporting: boolean
submittingWorkflow: boolean
// Actions
onSetActiveTab: (tab: ActiveTab) => void
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
onSelectAnnotation: (ann: Annotation | null) => void
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => Promise<void>
onDeleteAnnotation: (id: string) => Promise<void>
onSetGutachten: (text: string | ((prev: string) => string)) => void
onGenerateGutachten: () => Promise<void>
onSaveGutachten: () => Promise<void>
onExportGutachtenPDF: () => Promise<void>
onSubmitErstkorrektur: () => Promise<void>
onStartZweitkorrektur: (zkId: string) => Promise<void>
onSubmitZweitkorrektur: () => Promise<void>
onOpenEinigung: () => void
}
export function CorrectionPanel({
klausur,
klausurId,
studentId,
annotations,
gradeInfo,
criteriaScores,
gutachten,
workflow,
totals,
selectedAnnotation,
activeTab,
generatingGutachten,
saving,
exporting,
submittingWorkflow,
onSetActiveTab,
onCriteriaChange,
onSelectTool,
onSelectAnnotation,
onUpdateAnnotation,
onDeleteAnnotation,
onSetGutachten,
onGenerateGutachten,
onSaveGutachten,
onExportGutachtenPDF,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CorrectionPanelProps) {
return (
<div className="w-1/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Tabs */}
<div className="border-b border-slate-200">
<nav className="flex">
{[
{ id: 'kriterien' as const, label: 'Kriterien' },
{ id: 'annotationen' as const, label: `Notizen (${annotations.length})` },
{ id: 'gutachten' as const, label: 'Gutachten' },
{ id: 'eh-vorschlaege' as const, label: 'EH' },
].map((tab) => (
<button
key={tab.id}
onClick={() => onSetActiveTab(tab.id)}
className={`flex-1 px-2 py-3 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
<div className="flex-1 overflow-auto p-4">
{/* Kriterien Tab */}
{activeTab === 'kriterien' && gradeInfo && (
<CriteriaTab
gradeInfo={gradeInfo}
criteriaScores={criteriaScores}
annotations={annotations}
totals={totals}
workflow={workflow}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
onCriteriaChange={onCriteriaChange}
onSelectTool={onSelectTool}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
)}
{/* Annotationen Tab */}
{activeTab === 'annotationen' && (
<div className="h-full -m-4">
<AnnotationPanel
annotations={annotations}
selectedAnnotation={selectedAnnotation}
onSelectAnnotation={onSelectAnnotation}
onUpdateAnnotation={onUpdateAnnotation}
onDeleteAnnotation={onDeleteAnnotation}
/>
</div>
)}
{/* Gutachten Tab */}
{activeTab === 'gutachten' && (
<GutachtenTab
gutachten={gutachten}
generatingGutachten={generatingGutachten}
saving={saving}
exporting={exporting}
onSetGutachten={onSetGutachten}
onGenerateGutachten={onGenerateGutachten}
onSaveGutachten={onSaveGutachten}
onExportGutachtenPDF={onExportGutachtenPDF}
/>
)}
{/* EH-Vorschlaege Tab */}
{activeTab === 'eh-vorschlaege' && (
<div className="h-full -m-4">
<EHSuggestionPanel
studentId={studentId}
klausurId={klausurId}
hasEH={!!klausur?.eh_id || true}
apiBase={API_BASE}
onInsertSuggestion={(text, criterion) => {
onSetGutachten((prev: string) =>
prev
? `${prev}\n\n[${criterion.toUpperCase()}]: ${text}`
: `[${criterion.toUpperCase()}]: ${text}`,
)
onSetActiveTab('gutachten')
}}
/>
</div>
)}
</div>
</div>
)
}
// ---- Gutachten sub-component ----
interface GutachtenTabProps {
gutachten: string
generatingGutachten: boolean
saving: boolean
exporting: boolean
onSetGutachten: (text: string | ((prev: string) => string)) => void
onGenerateGutachten: () => void
onSaveGutachten: () => void
onExportGutachtenPDF: () => void
}
function GutachtenTab({
gutachten,
generatingGutachten,
saving,
exporting,
onSetGutachten,
onGenerateGutachten,
onSaveGutachten,
onExportGutachtenPDF,
}: GutachtenTabProps) {
return (
<div className="h-full flex flex-col">
<textarea
value={gutachten}
onChange={(e) => onSetGutachten(e.target.value)}
placeholder="Gutachten hier eingeben oder generieren lassen..."
className="flex-1 w-full p-3 border border-slate-300 rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
/>
<div className="flex gap-2 mt-4">
<button
onClick={onGenerateGutachten}
disabled={generatingGutachten}
className="flex-1 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50 disabled:opacity-50"
>
{generatingGutachten ? 'Generiere...' : 'Neu generieren'}
</button>
<button
onClick={onSaveGutachten}
disabled={saving}
className="flex-1 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
{/* PDF Export */}
{gutachten && (
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
<button
onClick={onExportGutachtenPDF}
disabled={exporting}
className="flex-1 py-2 border border-slate-300 text-slate-600 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center justify-center gap-2 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="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...' : 'Als PDF exportieren'}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,384 @@
'use client'
/**
* Kriterien tab content: scoring sliders, annotation counts per criterion,
* totals summary, and workflow action buttons.
*/
import type { Annotation, AnnotationType, GradeInfo, CriteriaScores } from '../../../types'
import { ANNOTATION_COLORS } from '../../../types'
import type { ExaminerWorkflow, GradeTotals } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface CriteriaTabProps {
gradeInfo: GradeInfo
criteriaScores: CriteriaScores
annotations: Annotation[]
totals: GradeTotals
workflow: ExaminerWorkflow | null
generatingGutachten: boolean
submittingWorkflow: boolean
gutachten: string
onCriteriaChange: (criterion: string, value: number) => void
onSelectTool: (tool: AnnotationType) => void
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
export function CriteriaTab({
gradeInfo,
criteriaScores,
annotations,
totals,
workflow,
generatingGutachten,
submittingWorkflow,
gutachten,
onCriteriaChange,
onSelectTool,
onGenerateGutachten,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CriteriaTabProps) {
return (
<div className="space-y-4">
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => {
const score = criteriaScores[key] || 0
const linkedAnnotations = annotations.filter(
(a) => a.linked_criterion === key || a.type === key,
)
const errorCount = linkedAnnotations.length
const severityCounts = {
minor: linkedAnnotations.filter((a) => a.severity === 'minor').length,
major: linkedAnnotations.filter((a) => a.severity === 'major').length,
critical: linkedAnnotations.filter((a) => a.severity === 'critical').length,
}
const criterionColor = ANNOTATION_COLORS[key as AnnotationType] || '#6b7280'
return (
<div key={key} className="bg-slate-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: criterionColor }}
/>
<span className="font-medium text-slate-800">{criterion.name}</span>
<span className="text-xs text-slate-500">({criterion.weight}%)</span>
</div>
<div className="text-lg font-bold text-slate-800">{score}%</div>
</div>
{/* Annotation count for this criterion */}
{errorCount > 0 && (
<div className="flex items-center gap-2 mb-2 text-xs">
<span className="text-slate-500">{errorCount} Markierungen:</span>
{severityCounts.minor > 0 && (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded">
{severityCounts.minor} leicht
</span>
)}
{severityCounts.major > 0 && (
<span className="px-1.5 py-0.5 bg-orange-100 text-orange-700 rounded">
{severityCounts.major} mittel
</span>
)}
{severityCounts.critical > 0 && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
{severityCounts.critical} schwer
</span>
)}
</div>
)}
{/* Slider */}
<input
type="range"
min="0"
max="100"
value={score}
onChange={(e) => onCriteriaChange(key, parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer"
style={{ accentColor: criterionColor }}
/>
{/* Quick buttons */}
<div className="flex gap-1 mt-2">
{[0, 25, 50, 75, 100].map((val) => (
<button
key={val}
onClick={() => onCriteriaChange(key, val)}
className={`flex-1 py-1 text-xs rounded transition-colors ${
score === val
? 'text-white'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
}`}
style={score === val ? { backgroundColor: criterionColor } : undefined}
>
{val}%
</button>
))}
</div>
{/* Quick add annotation button for RS/Grammatik */}
{(key === 'rechtschreibung' || key === 'grammatik') && (
<button
onClick={() => onSelectTool(key as AnnotationType)}
className="mt-2 w-full py-1 text-xs border rounded hover:bg-slate-100 flex items-center justify-center gap-1"
style={{ borderColor: criterionColor, color: criterionColor }}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{key === 'rechtschreibung' ? 'RS-Fehler' : 'Grammatik-Fehler'} markieren
</button>
)}
</div>
)
})}
{/* Total and action buttons */}
<CriteriaTotals
totals={totals}
workflow={workflow}
generatingGutachten={generatingGutachten}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
onGenerateGutachten={onGenerateGutachten}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
</div>
)
}
// ---- Sub-component: totals + workflow buttons ----
interface CriteriaTotalsProps {
totals: GradeTotals
workflow: ExaminerWorkflow | null
generatingGutachten: boolean
submittingWorkflow: boolean
gutachten: string
onGenerateGutachten: () => void
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
function CriteriaTotals({
totals,
workflow,
generatingGutachten,
submittingWorkflow,
gutachten,
onGenerateGutachten,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: CriteriaTotalsProps) {
return (
<div className="border-t border-slate-200 pt-4 mt-4">
<div className="flex justify-between items-center mb-4">
<span className="font-semibold text-slate-800">Gesamtergebnis</span>
<div className="text-right">
<div className="text-2xl font-bold text-primary-600">
{totals.gradePoints} Punkte
</div>
<div className="text-sm text-slate-500">
({totals.weighted}%) - Note {GRADE_LABELS[totals.gradePoints]}
</div>
</div>
</div>
<div className="space-y-2">
{/* Generate Gutachten */}
<button
onClick={onGenerateGutachten}
disabled={generatingGutachten}
className="w-full py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{generatingGutachten ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-slate-700"></div>
Generiere Gutachten...
</>
) : (
<>
<svg className="w-4 h-4" 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>
Gutachten generieren
</>
)}
</button>
{/* Workflow buttons */}
<WorkflowButtons
workflow={workflow}
submittingWorkflow={submittingWorkflow}
gutachten={gutachten}
totals={totals}
onSubmitErstkorrektur={onSubmitErstkorrektur}
onStartZweitkorrektur={onStartZweitkorrektur}
onSubmitZweitkorrektur={onSubmitZweitkorrektur}
onOpenEinigung={onOpenEinigung}
/>
</div>
</div>
)
}
// ---- Sub-component: workflow action buttons ----
interface WorkflowButtonsProps {
workflow: ExaminerWorkflow | null
submittingWorkflow: boolean
gutachten: string
totals: GradeTotals
onSubmitErstkorrektur: () => void
onStartZweitkorrektur: (zkId: string) => void
onSubmitZweitkorrektur: () => void
onOpenEinigung: () => void
}
function WorkflowButtons({
workflow,
submittingWorkflow,
gutachten,
totals,
onSubmitErstkorrektur,
onStartZweitkorrektur,
onSubmitZweitkorrektur,
onOpenEinigung,
}: WorkflowButtonsProps) {
return (
<>
{/* EK submit */}
{(!workflow || workflow.workflow_status === 'not_started' || workflow.workflow_status === 'ek_in_progress') && (
<button
onClick={onSubmitErstkorrektur}
disabled={submittingWorkflow || !gutachten}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingWorkflow ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird abgeschlossen...
</>
) : (
<>
<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>
Erstkorrektur abschliessen
</>
)}
</button>
)}
{/* Forward to ZK */}
{workflow?.workflow_status === 'ek_completed' && workflow.user_role === 'ek' && (
<button
onClick={() => {
const zkId = prompt('Zweitkorrektor-ID eingeben:')
if (zkId) onStartZweitkorrektur(zkId)
}}
disabled={submittingWorkflow}
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
Zur Zweitkorrektur weiterleiten
</button>
)}
{/* ZK submit */}
{(workflow?.workflow_status === 'zk_assigned' || workflow?.workflow_status === 'zk_in_progress') &&
workflow?.user_role === 'zk' && (
<button
onClick={onSubmitZweitkorrektur}
disabled={submittingWorkflow || !gutachten}
className="w-full py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{submittingWorkflow ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird abgeschlossen...
</>
) : (
<>
<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>
Zweitkorrektur abschliessen
</>
)}
</button>
)}
{/* Einigung */}
{workflow?.workflow_status === 'einigung_required' && (
<button
onClick={onOpenEinigung}
className="w-full py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Einigung starten
</button>
)}
{/* Completed */}
{workflow?.workflow_status === 'completed' && (
<div className="bg-green-100 text-green-800 p-4 rounded-lg text-center">
<div className="text-2xl font-bold">
Endnote: {workflow.final_grade} Punkte
</div>
<div className="text-sm mt-1">
({GRADE_LABELS[workflow.final_grade || 0]}) -{' '}
{workflow.consensus_type === 'auto'
? 'Auto-Konsens'
: workflow.consensus_type === 'drittkorrektur'
? 'Drittkorrektur'
: 'Einigung'}
</div>
</div>
)}
{/* EK/ZK comparison */}
{workflow?.first_result && workflow?.second_result && workflow?.workflow_status !== 'completed' && (
<div className="bg-slate-50 rounded-lg p-3 mt-2">
<div className="text-xs text-slate-500 mb-2">Notenvergleich</div>
<div className="flex justify-between">
<div className="text-center">
<div className="text-sm text-slate-500">EK</div>
<div className="font-bold text-blue-600">{workflow.first_result.grade_points}P</div>
</div>
<div className="text-center">
<div className="text-sm text-slate-500">ZK</div>
<div className="font-bold text-amber-600">{workflow.second_result.grade_points}P</div>
</div>
<div className="text-center">
<div className="text-sm text-slate-500">Diff</div>
<div className={`font-bold ${(workflow.grade_difference || 0) >= 4 ? 'text-red-600' : 'text-slate-700'}`}>
{workflow.grade_difference}P
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,138 @@
'use client'
/**
* Left panel (2/3 width): Document viewer with annotation overlay,
* toolbar, page navigation, and collapsible OCR text.
*/
import { AnnotationLayer, AnnotationToolbar } from '../../../components'
import type { Annotation, AnnotationType, AnnotationPosition } from '../../../types'
interface DocumentViewerProps {
documentUrl: string | null
filePath?: string
ocrText?: string
zoom: number
currentPage: number
totalPages: number
annotations: Annotation[]
selectedTool: AnnotationType | null
selectedAnnotation: Annotation | null
annotationCounts: Record<AnnotationType, number>
onZoomChange: (zoom: number) => void
onSelectTool: (tool: AnnotationType | null) => void
onCurrentPageChange: (page: number | ((p: number) => number)) => void
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
onSelectAnnotation: (ann: Annotation) => void
}
export function DocumentViewer({
documentUrl,
filePath,
ocrText,
zoom,
currentPage,
totalPages,
annotations,
selectedTool,
selectedAnnotation,
annotationCounts,
onZoomChange,
onSelectTool,
onCurrentPageChange,
onCreateAnnotation,
onSelectAnnotation,
}: DocumentViewerProps) {
const pageAnnotations = annotations.filter((ann) => ann.page === currentPage)
return (
<div className="w-2/3 bg-white rounded-lg border border-slate-200 overflow-hidden flex flex-col">
{/* Toolbar */}
<AnnotationToolbar
selectedTool={selectedTool}
onSelectTool={onSelectTool}
zoom={zoom}
onZoomChange={onZoomChange}
annotationCounts={annotationCounts}
/>
{/* Document display with annotation overlay */}
<div className="flex-1 overflow-auto p-4 bg-slate-100">
{documentUrl ? (
<div
className="mx-auto bg-white shadow-lg relative"
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top center' }}
>
{filePath?.endsWith('.pdf') ? (
<iframe
src={documentUrl}
className="w-full h-[800px] border-0"
title="Studentenarbeit"
/>
) : (
<div className="relative">
<img
src={documentUrl}
alt="Studentenarbeit"
className="max-w-full"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-document.png'
}}
/>
{/* Annotation Layer Overlay */}
<AnnotationLayer
annotations={pageAnnotations}
selectedTool={selectedTool}
onCreateAnnotation={onCreateAnnotation}
onSelectAnnotation={onSelectAnnotation}
selectedAnnotationId={selectedAnnotation?.id}
/>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-full text-slate-400">
Kein Dokument verfuegbar
</div>
)}
</div>
{/* Page navigation */}
<div className="border-t border-slate-200 p-2 flex items-center justify-center gap-2 bg-slate-50">
<button
onClick={() => onCurrentPageChange((p: number) => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
>
<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>
</button>
<span className="text-sm">
Seite {currentPage} / {totalPages}
</span>
<button
onClick={() => onCurrentPageChange((p: number) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* OCR Text (collapsible) */}
{ocrText && (
<details className="border-t border-slate-200">
<summary className="p-3 bg-slate-50 cursor-pointer text-sm font-medium text-slate-600 hover:bg-slate-100">
OCR-Text anzeigen
</summary>
<div className="p-4 max-h-48 overflow-auto text-sm text-slate-700 bg-slate-50">
<pre className="whitespace-pre-wrap font-sans">{ocrText}</pre>
</div>
</details>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
/**
* Modal for the Einigung (agreement) process between EK and ZK.
*/
import type { ExaminerWorkflow } from './workspace-types'
import { GRADE_LABELS } from './workspace-types'
interface EinigungModalProps {
workflow: ExaminerWorkflow
einigungGrade: number
einigungNotes: string
submittingWorkflow: boolean
onGradeChange: (grade: number) => void
onNotesChange: (notes: string) => void
onSubmit: (type: 'agreed' | 'split' | 'escalated') => void
onClose: () => void
}
export function EinigungModal({
workflow,
einigungGrade,
einigungNotes,
submittingWorkflow,
onGradeChange,
onNotesChange,
onSubmit,
onClose,
}: EinigungModalProps) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg mx-4">
<h3 className="text-lg font-semibold mb-4">Einigung erforderlich</h3>
{/* Grade comparison */}
<div className="bg-slate-50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-sm text-slate-500">Erstkorrektor</div>
<div className="text-2xl font-bold text-blue-600">
{workflow.first_result?.grade_points || '-'} P
</div>
</div>
<div>
<div className="text-sm text-slate-500">Zweitkorrektor</div>
<div className="text-2xl font-bold text-amber-600">
{workflow.second_result?.grade_points || '-'} P
</div>
</div>
</div>
<div className="text-center mt-2 text-sm text-slate-500">
Differenz: {workflow.grade_difference} Punkte
</div>
</div>
{/* Final grade selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Endnote festlegen
</label>
<input
type="range"
min={Math.min(workflow.first_result?.grade_points || 0, workflow.second_result?.grade_points || 0) - 1}
max={Math.max(workflow.first_result?.grade_points || 15, workflow.second_result?.grade_points || 15) + 1}
value={einigungGrade}
onChange={(e) => onGradeChange(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-center text-2xl font-bold mt-2">
{einigungGrade} Punkte ({GRADE_LABELS[einigungGrade] || '-'})
</div>
</div>
{/* Notes */}
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Begruendung
</label>
<textarea
value={einigungNotes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Begruendung fuer die Einigung..."
className="w-full p-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => onSubmit('agreed')}
disabled={submittingWorkflow || !einigungNotes}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Einigung bestaetigen
</button>
<button
onClick={() => onSubmit('escalated')}
disabled={submittingWorkflow}
className="py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200"
>
Eskalieren
</button>
<button
onClick={onClose}
className="py-2 px-4 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
/**
* Top navigation bar with back link, student navigation,
* workflow status badges, and grade display.
*/
import Link from 'next/link'
import type { ExaminerWorkflow, GradeTotals } from './workspace-types'
import { WORKFLOW_STATUS_LABELS, ROLE_LABELS, GRADE_LABELS } from './workspace-types'
interface TopNavigationBarProps {
klausurId: string
currentIndex: number
studentsCount: number
workflow: ExaminerWorkflow | null
saving: boolean
totals: GradeTotals
onGoToStudent: (direction: 'prev' | 'next') => void
}
export function TopNavigationBar({
klausurId,
currentIndex,
studentsCount,
workflow,
saving,
totals,
onGoToStudent,
}: TopNavigationBarProps) {
return (
<div className="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between sticky top-0 z-10">
{/* Back link */}
<Link
href={`/education/klausur-korrektur/${klausurId}`}
className="text-primary-600 hover:text-primary-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
</Link>
{/* Student navigation */}
<div className="flex items-center gap-4">
<button
onClick={() => onGoToStudent('prev')}
disabled={currentIndex <= 0}
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-medium">
{currentIndex + 1} / {studentsCount}
</span>
<button
onClick={() => onGoToStudent('next')}
disabled={currentIndex >= studentsCount - 1}
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Workflow status and role */}
<div className="flex items-center gap-3">
{workflow && (
<div className="flex items-center gap-2">
<span
className={`px-2 py-1 text-xs font-medium rounded-full text-white ${
ROLE_LABELS[workflow.user_role]?.color || 'bg-slate-500'
}`}
>
{ROLE_LABELS[workflow.user_role]?.label || workflow.user_role}
</span>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.color || 'bg-slate-100'
}`}
>
{WORKFLOW_STATUS_LABELS[workflow.workflow_status]?.label || workflow.workflow_status}
</span>
{workflow.user_role === 'zk' && workflow.visibility_mode !== 'full' && (
<span className="px-2 py-1 text-xs bg-purple-100 text-purple-700 rounded-full">
{workflow.visibility_mode === 'blind' ? 'Blind-Modus' : 'Semi-Blind'}
</span>
)}
</div>
)}
{saving && (
<span className="text-sm text-slate-500 flex items-center gap-1">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-primary-600"></div>
Speichern...
</span>
)}
<div className="text-right">
<div className="text-lg font-bold text-slate-800">
{totals.gradePoints} Punkte
</div>
<div className="text-sm text-slate-500">
Note: {GRADE_LABELS[totals.gradePoints] || '-'}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export { useKorrekturWorkspace } from './useKorrekturWorkspace'
export { TopNavigationBar } from './TopNavigationBar'
export { EinigungModal } from './EinigungModal'
export { DocumentViewer } from './DocumentViewer'
export { CorrectionPanel } from './CorrectionPanel'
export { CriteriaTab } from './CriteriaTab'
export type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
export { API_BASE, GRADE_LABELS, WORKFLOW_STATUS_LABELS, ROLE_LABELS } from './workspace-types'

View File

@@ -0,0 +1,454 @@
'use client'
/**
* Custom hook encapsulating all state, data-fetching, CRUD operations,
* and workflow actions for the Korrektur-Workspace page.
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import type {
Klausur, StudentWork, Annotation, CriteriaScores,
GradeInfo, AnnotationType, AnnotationPosition,
} from '../../../types'
import type { ExaminerWorkflow, ActiveTab, GradeTotals } from './workspace-types'
import { API_BASE } from './workspace-types'
/** Download a blob from url and trigger browser download with given filename. */
async function downloadBlob(url: string, filename: string) {
const res = await fetch(url)
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const blobUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl; a.download = filename
document.body.appendChild(a); a.click()
document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl)
}
export function useKorrekturWorkspace(
klausurId: string,
studentId: string,
routerPush: (url: string) => void,
) {
// ---- Core data state ----
const [klausur, setKlausur] = useState<Klausur | null>(null)
const [student, setStudent] = useState<StudentWork | null>(null)
const [students, setStudents] = useState<StudentWork[]>([])
const [annotations, setAnnotations] = useState<Annotation[]>([])
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
const [workflow, setWorkflow] = useState<ExaminerWorkflow | null>(null)
// ---- UI flags ----
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<ActiveTab>('kriterien')
const [currentPage, setCurrentPage] = useState(1)
const [totalPages] = useState(1)
const [zoom, setZoom] = useState(100)
const [documentUrl, setDocumentUrl] = useState<string | null>(null)
const [generatingGutachten, setGeneratingGutachten] = useState(false)
const [exporting, setExporting] = useState(false)
// ---- Annotation state ----
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null)
// ---- Form state ----
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
const [gutachten, setGutachten] = useState('')
// ---- Einigung state ----
const [showEinigungModal, setShowEinigungModal] = useState(false)
const [einigungGrade, setEinigungGrade] = useState<number>(0)
const [einigungNotes, setEinigungNotes] = useState('')
const [submittingWorkflow, setSubmittingWorkflow] = useState(false)
// ---- Derived ----
const currentIndex = students.findIndex(s => s.id === studentId)
const annotationCounts = useMemo(() => {
const counts: Record<AnnotationType, number> = {
rechtschreibung: 0, grammatik: 0, inhalt: 0,
struktur: 0, stil: 0, comment: 0, highlight: 0,
}
annotations.forEach((ann) => {
counts[ann.type] = (counts[ann.type] || 0) + 1
})
return counts
}, [annotations])
// ---- Grade calculation ----
const calculateTotalPoints = useCallback((): GradeTotals => {
if (!gradeInfo?.criteria) return { raw: 0, weighted: 0, gradePoints: 0 }
let totalWeighted = 0
let totalWeight = 0
Object.entries(gradeInfo.criteria).forEach(([key, criterion]) => {
const score = criteriaScores[key] || 0
totalWeighted += score * (criterion.weight / 100)
totalWeight += criterion.weight
})
const percentage = totalWeight > 0 ? (totalWeighted / totalWeight) * 100 : 0
let gradePoints = 0
const thresholds = [
{ points: 15, min: 95 }, { points: 14, min: 90 }, { points: 13, min: 85 },
{ points: 12, min: 80 }, { points: 11, min: 75 }, { points: 10, min: 70 },
{ points: 9, min: 65 }, { points: 8, min: 60 }, { points: 7, min: 55 },
{ points: 6, min: 50 }, { points: 5, min: 45 }, { points: 4, min: 40 },
{ points: 3, min: 33 }, { points: 2, min: 27 }, { points: 1, min: 20 },
]
for (const t of thresholds) {
if (percentage >= t.min) { gradePoints = t.points; break }
}
return { raw: Math.round(totalWeighted), weighted: Math.round(percentage), gradePoints }
}, [gradeInfo, criteriaScores])
const totals = calculateTotalPoints()
// ---- Data fetching ----
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 studentsRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
if (studentsRes.ok) {
const data = await studentsRes.json()
setStudents(Array.isArray(data) ? data : data.students || [])
}
const studentRes = await fetch(`${API_BASE}/api/v1/students/${studentId}`)
if (studentRes.ok) {
const studentData = await studentRes.json()
setStudent(studentData)
setCriteriaScores(studentData.criteria_scores || {})
setGutachten(studentData.gutachten || '')
}
const gradeInfoRes = await fetch(`${API_BASE}/api/v1/grade-info`)
if (gradeInfoRes.ok) setGradeInfo(await gradeInfoRes.json())
const workflowRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner-workflow`)
if (workflowRes.ok) {
const workflowData = await workflowRes.json()
setWorkflow(workflowData)
if (workflowData.workflow_status === 'einigung_required' && workflowData.first_result && workflowData.second_result) {
const avgGrade = Math.round((workflowData.first_result.grade_points + workflowData.second_result.grade_points) / 2)
setEinigungGrade(avgGrade)
}
}
const annotationsEndpoint = workflow?.user_role === 'zk'
? `${API_BASE}/api/v1/students/${studentId}/annotations-filtered`
: `${API_BASE}/api/v1/students/${studentId}/annotations`
const annotationsRes = await fetch(annotationsEndpoint)
if (annotationsRes.ok) {
const annotationsData = await annotationsRes.json()
setAnnotations(Array.isArray(annotationsData) ? annotationsData : annotationsData.annotations || [])
}
setDocumentUrl(`${API_BASE}/api/v1/students/${studentId}/file`)
setError(null)
} catch (err) {
console.error('Failed to fetch data:', err)
setError('Fehler beim Laden der Daten')
} finally {
setLoading(false)
}
}, [klausurId, studentId])
useEffect(() => { fetchData() }, [fetchData])
// ---- Annotation CRUD ----
const createAnnotation = useCallback(async (position: AnnotationPosition, type: AnnotationType) => {
try {
const newAnnotation = {
page: currentPage, position, type, text: '',
severity: type === 'rechtschreibung' || type === 'grammatik' ? 'minor' : 'major',
role: 'first_examiner',
linked_criterion: ['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].includes(type) ? type : undefined,
}
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/annotations`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAnnotation),
})
if (res.ok) {
const created = await res.json()
setAnnotations((prev) => [...prev, created])
setSelectedAnnotation(created)
setActiveTab('annotationen')
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Erstellen der Annotation')
}
} catch (err) {
console.error('Failed to create annotation:', err)
setError('Fehler beim Erstellen der Annotation')
}
}, [studentId, currentPage])
const updateAnnotation = useCallback(async (id: string, updates: Partial<Annotation>) => {
try {
const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (res.ok) {
const updated = await res.json()
setAnnotations((prev) => prev.map((ann) => (ann.id === id ? updated : ann)))
if (selectedAnnotation?.id === id) setSelectedAnnotation(updated)
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Aktualisieren der Annotation')
}
} catch (err) {
console.error('Failed to update annotation:', err)
setError('Fehler beim Aktualisieren der Annotation')
}
}, [selectedAnnotation?.id])
const deleteAnnotation = useCallback(async (id: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/annotations/${id}`, { method: 'DELETE' })
if (res.ok) {
setAnnotations((prev) => prev.filter((ann) => ann.id !== id))
if (selectedAnnotation?.id === id) setSelectedAnnotation(null)
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Loeschen der Annotation')
}
} catch (err) {
console.error('Failed to delete annotation:', err)
setError('Fehler beim Loeschen der Annotation')
}
}, [selectedAnnotation?.id])
// ---- Criteria ----
const saveCriteriaScores = useCallback(async (newScores: CriteriaScores) => {
try {
setSaving(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/criteria`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criteria_scores: newScores }),
})
if (res.ok) { setStudent(await res.json()) }
else { setError('Fehler beim Speichern') }
} catch (err) {
console.error('Failed to save criteria:', err)
setError('Fehler beim Speichern')
} finally { setSaving(false) }
}, [studentId])
const handleCriteriaChange = (criterion: string, value: number) => {
const newScores = { ...criteriaScores, [criterion]: value }
setCriteriaScores(newScores)
saveCriteriaScores(newScores)
}
// ---- Gutachten ----
const saveGutachten = useCallback(async () => {
try {
setSaving(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gutachten }),
})
if (res.ok) { setStudent(await res.json()) }
else { setError('Fehler beim Speichern') }
} catch (err) {
console.error('Failed to save gutachten:', err)
setError('Fehler beim Speichern')
} finally { setSaving(false) }
}, [studentId, gutachten])
const generateGutachten = useCallback(async () => {
try {
setGeneratingGutachten(true)
setError(null)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/gutachten/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criteria_scores: criteriaScores }),
})
if (res.ok) {
const data = await res.json()
const generatedText = [data.einleitung || '', '', data.hauptteil || '', '', data.fazit || '']
.filter(Boolean).join('\n\n')
setGutachten(generatedText)
setActiveTab('gutachten')
} else {
const errorData = await res.json()
setError(errorData.detail || 'Fehler bei der Gutachten-Generierung')
}
} catch (err) {
console.error('Failed to generate gutachten:', err)
setError('Fehler bei der Gutachten-Generierung')
} finally { setGeneratingGutachten(false) }
}, [studentId, criteriaScores])
// ---- PDF Export ----
const exportGutachtenPDF = useCallback(async () => {
try {
setExporting(true); setError(null)
const name = student?.anonym_id?.replace(/\s+/g, '_') || 'Student'
await downloadBlob(
`${API_BASE}/api/v1/students/${studentId}/export/gutachten`,
`Gutachten_${name}_${new Date().toISOString().split('T')[0]}.pdf`,
)
} catch (err) {
console.error('Failed to export PDF:', err)
setError('Fehler beim PDF-Export')
} finally { setExporting(false) }
}, [studentId, student?.anonym_id])
const exportAnnotationsPDF = useCallback(async () => {
try {
setExporting(true); setError(null)
const name = student?.anonym_id?.replace(/\s+/g, '_') || 'Student'
await downloadBlob(
`${API_BASE}/api/v1/students/${studentId}/export/annotations`,
`Anmerkungen_${name}_${new Date().toISOString().split('T')[0]}.pdf`,
)
} catch (err) {
console.error('Failed to export annotations PDF:', err)
setError('Fehler beim PDF-Export')
} finally { setExporting(false) }
}, [studentId, student?.anonym_id])
// ---- Workflow actions ----
const submitErstkorrektur = useCallback(async () => {
try {
setSubmittingWorkflow(true)
const assignRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ examiner_id: 'current-user', examiner_role: 'first_examiner' }),
})
if (!assignRes.ok && assignRes.status !== 400) {
const err = await assignRes.json()
throw new Error(err.detail || 'Fehler bei der Zuweisung')
}
const submitRes = await fetch(`${API_BASE}/api/v1/students/${studentId}/examiner/result`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade_points: totals.gradePoints, notes: gutachten }),
})
if (submitRes.ok) { fetchData() }
else {
const err = await submitRes.json()
setError(err.detail || 'Fehler beim Abschliessen der Erstkorrektur')
}
} catch (err) {
console.error('Failed to submit Erstkorrektur:', err)
setError('Fehler beim Abschliessen der Erstkorrektur')
} finally { setSubmittingWorkflow(false) }
}, [studentId, totals.gradePoints, gutachten, fetchData])
const startZweitkorrektur = useCallback(async (zweitkorrektorId: string) => {
try {
setSubmittingWorkflow(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/start-zweitkorrektur`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zweitkorrektor_id: zweitkorrektorId }),
})
if (res.ok) { fetchData() }
else {
const err = await res.json()
setError(err.detail || 'Fehler beim Starten der Zweitkorrektur')
}
} catch (err) {
console.error('Failed to start Zweitkorrektur:', err)
setError('Fehler beim Starten der Zweitkorrektur')
} finally { setSubmittingWorkflow(false) }
}, [studentId, fetchData])
const submitZweitkorrektur = useCallback(async () => {
try {
setSubmittingWorkflow(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/submit-zweitkorrektur`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grade_points: totals.gradePoints, criteria_scores: criteriaScores,
gutachten: gutachten ? { text: gutachten } : null, notes: '',
}),
})
if (res.ok) {
const result = await res.json()
if (result.workflow_status === 'completed') {
alert(`Auto-Konsens erreicht! Endnote: ${result.final_grade} Punkte`)
} else if (result.workflow_status === 'einigung_required') {
setShowEinigungModal(true)
} else if (result.workflow_status === 'drittkorrektur_required') {
alert(`Drittkorrektur erforderlich: Differenz ${result.grade_difference} Punkte`)
}
fetchData()
} else {
const err = await res.json()
setError(err.detail || 'Fehler beim Abschliessen der Zweitkorrektur')
}
} catch (err) {
console.error('Failed to submit Zweitkorrektur:', err)
setError('Fehler beim Abschliessen der Zweitkorrektur')
} finally { setSubmittingWorkflow(false) }
}, [studentId, totals.gradePoints, criteriaScores, gutachten, fetchData])
const submitEinigung = useCallback(async (type: 'agreed' | 'split' | 'escalated') => {
try {
setSubmittingWorkflow(true)
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}/einigung`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ final_grade: einigungGrade, einigung_notes: einigungNotes, einigung_type: type }),
})
if (res.ok) {
const result = await res.json()
setShowEinigungModal(false)
if (result.workflow_status === 'drittkorrektur_required') {
alert('Eskaliert zu Drittkorrektur')
} else {
alert(`Einigung abgeschlossen: Endnote ${result.final_grade} Punkte`)
}
fetchData()
} else {
const err = await res.json()
setError(err.detail || 'Fehler bei der Einigung')
}
} catch (err) {
console.error('Failed to submit Einigung:', err)
setError('Fehler bei der Einigung')
} finally { setSubmittingWorkflow(false) }
}, [studentId, einigungGrade, einigungNotes, fetchData])
// ---- Navigation ----
const goToStudent = (direction: 'prev' | 'next') => {
const newIndex = direction === 'prev' ? currentIndex - 1 : currentIndex + 1
if (newIndex >= 0 && newIndex < students.length) {
routerPush(`/education/klausur-korrektur/${klausurId}/${students[newIndex].id}`)
}
}
return {
// State
klausur, student, students, annotations, gradeInfo, workflow,
loading, saving, error, activeTab, currentPage, totalPages,
zoom, documentUrl, generatingGutachten, exporting,
selectedTool, selectedAnnotation,
criteriaScores, gutachten,
showEinigungModal, einigungGrade, einigungNotes, submittingWorkflow,
currentIndex, annotationCounts, totals,
// Actions
setActiveTab, setCurrentPage, setZoom,
setSelectedTool, setSelectedAnnotation,
setGutachten, setError,
setShowEinigungModal, setEinigungGrade, setEinigungNotes,
createAnnotation, updateAnnotation, deleteAnnotation,
handleCriteriaChange, saveGutachten, generateGutachten,
exportGutachtenPDF, exportAnnotationsPDF,
submitErstkorrektur, startZweitkorrektur, submitZweitkorrektur, submitEinigung,
goToStudent,
}
}

View File

@@ -0,0 +1,93 @@
/**
* Types and constants for the Korrektur-Workspace page.
*/
import type { CriteriaScores } from '../../../types'
// ---- Examiner workflow types ----
export interface ExaminerInfo {
id: string
assigned_at: string
notes?: string
}
export interface ExaminerResult {
grade_points: number
criteria_scores?: CriteriaScores
notes?: string
submitted_at: string
}
export interface ExaminerWorkflow {
student_id: string
workflow_status: string
visibility_mode: string
user_role: 'ek' | 'zk' | 'dk' | 'viewer'
first_examiner?: ExaminerInfo
second_examiner?: ExaminerInfo
third_examiner?: ExaminerInfo
first_result?: ExaminerResult
first_result_visible?: boolean
second_result?: ExaminerResult
third_result?: ExaminerResult
grade_difference?: number
final_grade?: number
consensus_reached?: boolean
consensus_type?: string
einigung?: {
final_grade: number
notes: string
type: string
submitted_by: string
submitted_at: string
ek_grade: number
zk_grade: number
}
drittkorrektur_reason?: string
}
// ---- Active tab ----
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
// ---- Totals from grade calculation ----
export interface GradeTotals {
raw: number
weighted: number
gradePoints: number
}
// ---- Constants ----
/** Same-origin proxy to avoid CORS issues */
export const API_BASE = '/klausur-api'
export 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',
}
export const WORKFLOW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' },
ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' },
ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' },
zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' },
zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' },
zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' },
einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' },
einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' },
drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' },
drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' },
drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' },
completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' },
}
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
ek: { label: 'Erstkorrektor', color: 'bg-blue-500' },
zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' },
dk: { label: 'Drittkorrektor', color: 'bg-purple-500' },
viewer: { label: 'Betrachter', color: 'bg-slate-500' },
}

View File

@@ -0,0 +1,337 @@
'use client'
/**
* DirektuploadTab - 3-step direct upload wizard
*/
import type { TabId } from './constants'
import type { DirektuploadForm } from './types'
interface DirektuploadTabProps {
direktForm: DirektuploadForm
setDirektForm: React.Dispatch<React.SetStateAction<DirektuploadForm>>
direktStep: 1 | 2 | 3
setDirektStep: React.Dispatch<React.SetStateAction<1 | 2 | 3>>
uploading: boolean
onUpload: () => void
onNavigate: (tab: TabId) => void
}
export function DirektuploadTab({
direktForm,
setDirektForm,
direktStep,
setDirektStep,
uploading,
onUpload,
onNavigate,
}: DirektuploadTabProps) {
return (
<div className="max-w-3xl mx-auto">
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<StepHeader
currentStep={direktStep}
onCancel={() => onNavigate('willkommen')}
/>
<div className="p-6">
{direktStep === 1 && (
<FileUploadStep
files={direktForm.files}
onFilesChange={(files) => setDirektForm(prev => ({ ...prev, files }))}
onNext={() => setDirektStep(2)}
/>
)}
{direktStep === 2 && (
<EHStep
aufgabentyp={direktForm.aufgabentyp}
ehText={direktForm.ehText}
onAufgabentypChange={(v) => setDirektForm(prev => ({ ...prev, aufgabentyp: v }))}
onEhTextChange={(v) => setDirektForm(prev => ({ ...prev, ehText: v }))}
onBack={() => setDirektStep(1)}
onNext={() => setDirektStep(3)}
/>
)}
{direktStep === 3 && (
<SummaryStep
direktForm={direktForm}
setDirektForm={setDirektForm}
uploading={uploading}
onBack={() => setDirektStep(2)}
onUpload={onUpload}
/>
)}
</div>
</div>
</div>
)
}
function StepHeader({ currentStep, onCancel }: { currentStep: number; onCancel: () => void }) {
return (
<div className="bg-slate-50 border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-slate-800">Schnellstart - Direkt Korrigieren</h2>
<button onClick={onCancel} className="text-sm text-slate-500 hover:text-slate-700">
Abbrechen
</button>
</div>
<div className="flex items-center gap-2">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center gap-2 flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
}`}>
{step}
</div>
<span className={`text-sm ${currentStep >= step ? 'text-slate-800' : 'text-slate-400'}`}>
{step === 1 ? 'Arbeiten' : step === 2 ? 'Erwartungshorizont' : 'Starten'}
</span>
{step < 3 && <div className={`flex-1 h-1 rounded ${currentStep > step ? 'bg-blue-600' : 'bg-slate-200'}`} />}
</div>
))}
</div>
</div>
)
}
function FileUploadStep({
files,
onFilesChange,
onNext,
}: {
files: File[]
onFilesChange: (files: File[]) => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Schuelerarbeiten hochladen</h3>
<p className="text-sm text-slate-500 mb-4">
Laden Sie die eingescannten Klausuren hoch. Unterstuetzte Formate: PDF, JPG, PNG.
</p>
<div
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
files.length > 0 ? 'border-green-300 bg-green-50' : 'border-slate-300 hover:border-blue-400 hover:bg-blue-50'
}`}
onDrop={(e) => {
e.preventDefault()
const dropped = Array.from(e.dataTransfer.files)
onFilesChange([...files, ...dropped])
}}
onDragOver={(e) => e.preventDefault()}
>
<svg className="w-12 h-12 mx-auto text-slate-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-slate-600 mb-2">Dateien hier ablegen oder</p>
<label className="inline-block px-4 py-2 bg-blue-600 text-white rounded-lg cursor-pointer hover:bg-blue-700">
Dateien auswaehlen
<input
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
onChange={(e) => {
const selected = Array.from(e.target.files || [])
onFilesChange([...files, ...selected])
}}
/>
</label>
</div>
{files.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm text-slate-600">
<span>{files.length} Datei{files.length !== 1 ? 'en' : ''} ausgewaehlt</span>
<button
onClick={() => onFilesChange([])}
className="text-red-600 hover:text-red-700"
>
Alle entfernen
</button>
</div>
<div className="max-h-40 overflow-y-auto space-y-1">
{files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between bg-slate-50 px-3 py-2 rounded-lg text-sm">
<span className="truncate">{file.name}</span>
<button
onClick={() => onFilesChange(files.filter((_, i) => i !== idx))}
className="text-slate-400 hover:text-red-600"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={onNext}
disabled={files.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)
}
function EHStep({
aufgabentyp,
ehText,
onAufgabentypChange,
onEhTextChange,
onBack,
onNext,
}: {
aufgabentyp: string
ehText: string
onAufgabentypChange: (v: string) => void
onEhTextChange: (v: string) => void
onBack: () => void
onNext: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Erwartungshorizont (optional)</h3>
<p className="text-sm text-slate-500 mb-4">
Beschreiben Sie die Aufgabenstellung fuer bessere KI-Vorschlaege.
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp</label>
<select
value={aufgabentyp}
onChange={(e) => onAufgabentypChange(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">-- Waehlen Sie einen Aufgabentyp --</option>
<option value="textanalyse_pragmatisch">Textanalyse (Sachtexte)</option>
<option value="gedichtanalyse">Gedichtanalyse</option>
<option value="prosaanalyse">Prosaanalyse</option>
<option value="dramenanalyse">Dramenanalyse</option>
<option value="eroerterung_textgebunden">Textgebundene Eroerterung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabenstellung / Erwartungshorizont
</label>
<textarea
value={ehText}
onChange={(e) => onEhTextChange(e.target.value)}
placeholder="Beschreiben Sie hier die Aufgabenstellung..."
rows={6}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
</div>
<div className="flex justify-between">
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">
Zurueck
</button>
<button onClick={onNext} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Weiter
</button>
</div>
</div>
)
}
function SummaryStep({
direktForm,
setDirektForm,
uploading,
onBack,
onUpload,
}: {
direktForm: DirektuploadForm
setDirektForm: React.Dispatch<React.SetStateAction<DirektuploadForm>>
uploading: boolean
onBack: () => void
onUpload: () => void
}) {
return (
<div className="space-y-6">
<div>
<h3 className="font-medium text-slate-800 mb-2">Zusammenfassung</h3>
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Titel</span>
<input
type="text"
value={direktForm.klausurTitle}
onChange={(e) => setDirektForm(prev => ({ ...prev, klausurTitle: e.target.value }))}
className="text-sm font-medium text-slate-800 bg-white border border-slate-200 rounded px-2 py-1 text-right"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Anzahl Arbeiten</span>
<span className="text-sm font-medium text-slate-800">{direktForm.files.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Aufgabentyp</span>
<span className="text-sm font-medium text-slate-800">
{direktForm.aufgabentyp || 'Nicht angegeben'}
</span>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">Was passiert jetzt?</p>
<ol className="list-decimal list-inside mt-1 space-y-1 text-blue-700">
<li>Eine neue Klausur wird automatisch erstellt</li>
<li>Alle {direktForm.files.length} Arbeiten werden hochgeladen</li>
<li>OCR-Erkennung startet automatisch</li>
<li>Sie werden zur Korrektur-Ansicht weitergeleitet</li>
</ol>
</div>
</div>
</div>
</div>
<div className="flex justify-between">
<button onClick={onBack} className="px-6 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">
Zurueck
</button>
<button
onClick={onUpload}
disabled={uploading}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird hochgeladen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Korrektur starten
</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
'use client'
/**
* ErstellenTab - Form to create a new Klausur
*/
import type { TabId } from './constants'
import type { CreateKlausurForm, VorabiturEHForm, EHTemplate } from './types'
interface ErstellenTabProps {
form: CreateKlausurForm
setForm: React.Dispatch<React.SetStateAction<CreateKlausurForm>>
ehForm: VorabiturEHForm
setEhForm: React.Dispatch<React.SetStateAction<VorabiturEHForm>>
templates: EHTemplate[]
loadingTemplates: boolean
creating: boolean
onSubmit: (e: React.FormEvent) => void
onNavigate: (tab: TabId) => void
}
export function ErstellenTab({
form,
setForm,
ehForm,
setEhForm,
templates,
loadingTemplates,
creating,
onSubmit,
onNavigate,
}: ErstellenTabProps) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-slate-800 mb-6">Neue Klausur erstellen</h2>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel der Klausur *
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
placeholder="z.B. Deutsch LK Abitur 2025 - Kurs D1"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Fach</label>
<select
value={form.subject}
onChange={(e) => setForm(prev => ({ ...prev, subject: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="Deutsch">Deutsch</option>
<option value="Englisch">Englisch</option>
<option value="Mathematik">Mathematik</option>
<option value="Geschichte">Geschichte</option>
<option value="Biologie">Biologie</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Jahr</label>
<input
type="number"
value={form.year}
onChange={(e) => setForm(prev => ({ ...prev, year: parseInt(e.target.value) }))}
min={2020}
max={2030}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Semester / Pruefung</label>
<select
value={form.semester}
onChange={(e) => setForm(prev => ({ ...prev, semester: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="Abitur">Abitur</option>
<option value="Q1">Q1 (11/1)</option>
<option value="Q2">Q2 (11/2)</option>
<option value="Q3">Q3 (12/1)</option>
<option value="Q4">Q4 (12/2)</option>
<option value="Vorabitur">Vorabitur</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Modus</label>
<select
value={form.modus}
onChange={(e) => setForm(prev => ({ ...prev, modus: e.target.value as 'abitur' | 'vorabitur' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="abitur">Abitur (mit offiziellem EH)</option>
<option value="vorabitur">Vorabitur (eigener EH)</option>
</select>
</div>
</div>
{form.modus === 'vorabitur' && (
<VorabiturEHSection
ehForm={ehForm}
setEhForm={setEhForm}
templates={templates}
loadingTemplates={loadingTemplates}
/>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => onNavigate('klausuren')}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={creating}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{creating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Erstelle...
</>
) : (
'Klausur erstellen'
)}
</button>
</div>
</form>
</div>
</div>
)
}
function VorabiturEHSection({
ehForm,
setEhForm,
templates,
loadingTemplates,
}: {
ehForm: VorabiturEHForm
setEhForm: React.Dispatch<React.SetStateAction<VorabiturEHForm>>
templates: EHTemplate[]
loadingTemplates: boolean
}) {
return (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.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>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">Eigenen Erwartungshorizont erstellen</p>
<p>Waehlen Sie einen Aufgabentyp und beschreiben Sie die Aufgabenstellung.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabentyp *</label>
{loadingTemplates ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Lade Vorlagen...
</div>
) : (
<select
value={ehForm.aufgabentyp}
onChange={(e) => setEhForm(prev => ({ ...prev, aufgabentyp: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white"
>
<option value="">-- Aufgabentyp waehlen --</option>
{templates.map(t => (
<option key={t.aufgabentyp} value={t.aufgabentyp}>{t.name}</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Texttitel (optional)</label>
<input
type="text"
value={ehForm.text_titel}
onChange={(e) => setEhForm(prev => ({ ...prev, text_titel: e.target.value }))}
placeholder="z.B. 'Die Verwandlung'"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor (optional)</label>
<input
type="text"
value={ehForm.text_autor}
onChange={(e) => setEhForm(prev => ({ ...prev, text_autor: e.target.value }))}
placeholder="z.B. 'Franz Kafka'"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgabenstellung *</label>
<textarea
value={ehForm.aufgabenstellung}
onChange={(e) => setEhForm(prev => ({ ...prev, aufgabenstellung: e.target.value }))}
placeholder="Beschreiben Sie hier die konkrete Aufgabenstellung..."
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,138 @@
'use client'
/**
* KlausurenTab - List of all Klausuren with progress and actions
*/
import Link from 'next/link'
import type { Klausur } from '../types'
import type { TabId } from './constants'
interface KlausurenTabProps {
klausuren: Klausur[]
loading: boolean
onDelete: (id: string) => void
onNavigate: (tab: TabId) => void
}
export function KlausurenTab({ klausuren, loading, onDelete, onNavigate }: KlausurenTabProps) {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold text-slate-800">Alle Klausuren</h2>
<p className="text-sm text-slate-500">{klausuren.length} Klausuren insgesamt</p>
</div>
<button
onClick={() => onNavigate('erstellen')}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Klausur
</button>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
) : klausuren.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg border border-slate-200">
<svg className="mx-auto h-12 w-12 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-slate-900">Keine Klausuren</h3>
<p className="mt-1 text-sm text-slate-500">Erstellen Sie Ihre erste Klausur zum Korrigieren.</p>
<button
onClick={() => onNavigate('erstellen')}
className="mt-4 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Klausur erstellen
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{klausuren.map((klausur) => (
<KlausurCard key={klausur.id} klausur={klausur} onDelete={onDelete} />
))}
</div>
)}
</div>
)
}
function KlausurCard({ klausur, onDelete }: { klausur: Klausur; onDelete: (id: string) => void }) {
const progressPct = (klausur.student_count || 0) > 0
? ((klausur.completed_count || 0) / (klausur.student_count || 1)) * 100
: 0
return (
<div className="bg-white rounded-lg border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="p-4">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="font-semibold text-slate-800 truncate">{klausur.title}</h3>
<p className="text-sm text-slate-500">{klausur.subject} - {klausur.year}</p>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
klausur.modus === 'abitur'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}>
{klausur.modus === 'abitur' ? 'Abitur' : 'Vorabitur'}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600 mb-4">
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>{klausur.student_count || 0} Arbeiten</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{klausur.completed_count || 0} fertig</span>
</div>
</div>
{(klausur.student_count || 0) > 0 && (
<div className="mb-4">
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>Fortschritt</span>
<span>{Math.round(progressPct)}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
<div className="flex gap-2">
<Link
href={`/education/klausur-korrektur/${klausur.id}`}
className="flex-1 px-3 py-2 bg-purple-600 text-white text-sm text-center rounded-lg hover:bg-purple-700"
>
Korrigieren
</Link>
<button
onClick={() => onDelete(klausur.id)}
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg"
title="Loeschen"
>
<svg className="w-5 h-5" 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>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
/**
* StatistikenTab - Correction statistics overview
*/
import type { Klausur, GradeInfo } from '../types'
interface StatistikenTabProps {
klausuren: Klausur[]
gradeInfo: GradeInfo | null
}
export function StatistikenTab({ klausuren, gradeInfo }: StatistikenTabProps) {
const totalStudents = klausuren.reduce((sum, k) => sum + (k.student_count || 0), 0)
const totalCompleted = klausuren.reduce((sum, k) => sum + (k.completed_count || 0), 0)
const totalPending = totalStudents - totalCompleted
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-slate-800">Korrektur-Statistiken</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard value={klausuren.length} label="Klausuren" />
<StatCard value={totalStudents} label="Studentenarbeiten" />
<StatCard value={totalCompleted} label="Abgeschlossen" className="text-green-600" />
<StatCard value={totalPending} label="Ausstehend" className="text-orange-600" />
</div>
{gradeInfo && (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-semibold text-slate-800 mb-4">Bewertungskriterien (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{Object.entries(gradeInfo.criteria || {}).map(([key, criterion]) => (
<div key={key} className="text-center p-3 bg-slate-50 rounded-lg">
<div className="text-lg font-semibold text-slate-700">{criterion.weight}%</div>
<div className="text-sm text-slate-500">{criterion.name}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
function StatCard({
value,
label,
className,
}: {
value: number
label: string
className?: string
}) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className={`text-2xl font-bold ${className || 'text-slate-800'}`}>{value}</div>
<div className="text-sm text-slate-500">{label}</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
/**
* WillkommenTab - Welcome page with onboarding options
*/
import type { Klausur } from '../types'
import type { TabId } from './constants'
interface WillkommenTabProps {
klausuren: Klausur[]
onNavigate: (tab: TabId) => void
markAsVisited: () => void
}
export function WillkommenTab({ klausuren, onNavigate, markAsVisited }: WillkommenTabProps) {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-purple-500 to-purple-700 rounded-2xl mb-6">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-slate-800 mb-3">Willkommen zur Abiturklausur-Korrektur</h1>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
KI-gestuetzte Korrektur fuer Deutsch-Abiturklausuren nach dem 15-Punkte-System.
Sparen Sie bis zu 80% Zeit bei der Erstkorrektur.
</p>
</div>
<HowItWorksSection />
<div className="grid md:grid-cols-2 gap-6">
<div
className="bg-white border-2 border-slate-200 rounded-xl p-6 hover:border-purple-300 hover:shadow-lg transition-all cursor-pointer"
onClick={() => { markAsVisited(); onNavigate('erstellen'); }}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-800 mb-1">Neue Klausur erstellen</h3>
<p className="text-sm text-slate-600 mb-3">
Empfohlen fuer regelmaessige Nutzung. Erstellen Sie eine Klausur mit allen Metadaten.
</p>
<div className="mt-4 text-sm text-purple-600 font-medium flex items-center gap-1">
Klausur erstellen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
<div
className="bg-white border-2 border-slate-200 rounded-xl p-6 hover:border-blue-300 hover:shadow-lg transition-all cursor-pointer"
onClick={() => { markAsVisited(); onNavigate('direktupload'); }}
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-800 mb-1">Schnellstart - Direkt hochladen</h3>
<p className="text-sm text-slate-600 mb-3">
Ideal wenn Sie sofort loslegen moechten. Drag & Drop Upload.
</p>
<div className="mt-4 text-sm text-blue-600 font-medium flex items-center gap-1">
Schnellstart
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
</div>
{klausuren.length > 0 && (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 flex items-center justify-between">
<div>
<p className="font-medium text-slate-800">Sie haben {klausuren.length} Klausur{klausuren.length !== 1 ? 'en' : ''}</p>
<p className="text-sm text-slate-500">Setzen Sie Ihre Arbeit fort oder starten Sie eine neue Korrektur.</p>
</div>
<button
onClick={() => { markAsVisited(); onNavigate('klausuren'); }}
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 text-sm"
>
Zu meinen Klausuren
</button>
</div>
)}
</div>
)
}
function HowItWorksSection() {
const steps = [
{ step: 1, title: 'Arbeiten hochladen', desc: 'Scans der Schuelerarbeiten hochladen', emoji: '📤' },
{ step: 2, title: 'EH bereitstellen', desc: 'Erwartungshorizont hochladen oder erstellen', emoji: '📋' },
{ step: 3, title: 'KI korrigiert', desc: 'Automatische Bewertung erhalten', emoji: '🤖' },
{ step: 4, title: 'Pruefen & Anpassen', desc: 'Vorschlaege pruefen und finalisieren', emoji: '✅' },
]
return (
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
So funktioniert es
</h2>
<div className="grid md:grid-cols-4 gap-4">
{steps.map(({ step, title, desc, emoji }) => (
<div key={step} className="text-center">
<div className="text-3xl mb-2">{emoji}</div>
<div className="text-xs text-blue-600 font-medium mb-1">Schritt {step}</div>
<div className="font-medium text-slate-800 text-sm">{title}</div>
<div className="text-xs text-slate-500 mt-1">{desc}</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
/**
* Constants for Klausur-Korrektur page
*/
// API Base URL for klausur-service (same-origin proxy to avoid CORS)
export const API_BASE = '/klausur-api'
// Tab definitions
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
export const tabs: { id: TabId; name: string; icon: JSX.Element; hidden?: boolean }[] = [
{
id: 'willkommen',
name: 'Start',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
id: 'klausuren',
name: 'Klausuren',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 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>
),
},
{
id: 'erstellen',
name: 'Neue Klausur',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
id: 'direktupload',
name: 'Schnellstart',
hidden: true,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
),
},
{
id: 'statistiken',
name: 'Statistiken',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
]

View File

@@ -0,0 +1,34 @@
/**
* Local form types for Klausur-Korrektur page
*/
export interface CreateKlausurForm {
title: string
subject: string
year: number
semester: string
modus: 'abitur' | 'vorabitur'
}
export interface VorabiturEHForm {
aufgabentyp: string
titel: string
text_titel: string
text_autor: string
aufgabenstellung: string
}
export interface EHTemplate {
aufgabentyp: string
name: string
description: string
category: string
}
export interface DirektuploadForm {
files: File[]
ehFile: File | null
ehText: string
aufgabentyp: string
klausurTitle: string
}

View File

@@ -0,0 +1,322 @@
'use client'
/**
* Custom hook for Klausur-Korrektur page state and handlers
*/
import { useState, useEffect, useCallback } from 'react'
import type { Klausur, GradeInfo } from '../types'
import type { TabId } from './constants'
import type {
CreateKlausurForm,
VorabiturEHForm,
EHTemplate,
DirektuploadForm,
} from './types'
import { API_BASE } from './constants'
export function useKlausurKorrektur() {
const [activeTab, setActiveTab] = useState<TabId>(() => {
if (typeof window !== 'undefined') {
const hasVisited = localStorage.getItem('klausur_korrektur_visited')
return hasVisited ? 'klausuren' : 'willkommen'
}
return 'willkommen'
})
const [klausuren, setKlausuren] = useState<Klausur[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [gradeInfo, setGradeInfo] = useState<GradeInfo | null>(null)
const [templates, setTemplates] = useState<EHTemplate[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [form, setForm] = useState<CreateKlausurForm>({
title: '',
subject: 'Deutsch',
year: new Date().getFullYear(),
semester: 'Abitur',
modus: 'abitur',
})
const [ehForm, setEhForm] = useState<VorabiturEHForm>({
aufgabentyp: '',
titel: '',
text_titel: '',
text_autor: '',
aufgabenstellung: '',
})
const [direktForm, setDirektForm] = useState<DirektuploadForm>({
files: [],
ehFile: null,
ehText: '',
aufgabentyp: '',
klausurTitle: `Schnellkorrektur ${new Date().toLocaleDateString('de-DE')}`,
})
const [direktStep, setDirektStep] = useState<1 | 2 | 3>(1)
const [uploading, setUploading] = useState(false)
const fetchKlausuren = useCallback(async () => {
try {
setLoading(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren`)
if (res.ok) {
const data = await res.json()
setKlausuren(Array.isArray(data) ? data : data.klausuren || [])
setError(null)
} else {
setError(`Fehler beim Laden: ${res.status}`)
}
} catch (err) {
console.error('Failed to fetch klausuren:', err)
setError('Verbindung zum Klausur-Service fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
const fetchGradeInfo = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/grade-info`)
if (res.ok) {
const data = await res.json()
setGradeInfo(data)
}
} catch (err) {
console.error('Failed to fetch grade info:', err)
}
}, [])
const fetchTemplates = useCallback(async () => {
try {
setLoadingTemplates(true)
const res = await fetch(`${API_BASE}/api/v1/vorabitur/templates`)
if (res.ok) {
const data = await res.json()
setTemplates(data.templates || [])
}
} catch (err) {
console.error('Failed to fetch templates:', err)
} finally {
setLoadingTemplates(false)
}
}, [])
useEffect(() => {
fetchKlausuren()
fetchGradeInfo()
}, [fetchKlausuren, fetchGradeInfo])
useEffect(() => {
if (form.modus === 'vorabitur' && templates.length === 0) {
fetchTemplates()
}
}, [form.modus, templates.length, fetchTemplates])
const handleCreateKlausur = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.title.trim()) {
setError('Bitte einen Titel eingeben')
return
}
if (form.modus === 'vorabitur') {
if (!ehForm.aufgabentyp) {
setError('Bitte einen Aufgabentyp auswaehlen')
return
}
if (!ehForm.aufgabenstellung.trim()) {
setError('Bitte die Aufgabenstellung eingeben')
return
}
}
try {
setCreating(true)
const res = await fetch(`${API_BASE}/api/v1/klausuren`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!res.ok) {
const errorData = await res.json()
setError(errorData.detail || 'Fehler beim Erstellen')
return
}
const newKlausur = await res.json()
if (form.modus === 'vorabitur') {
const ehRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/vorabitur-eh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aufgabentyp: ehForm.aufgabentyp,
titel: ehForm.titel || `EH: ${form.title}`,
text_titel: ehForm.text_titel || null,
text_autor: ehForm.text_autor || null,
aufgabenstellung: ehForm.aufgabenstellung,
}),
})
if (!ehRes.ok) {
console.error('Failed to create EH:', await ehRes.text())
setError('Klausur erstellt, aber Erwartungshorizont konnte nicht erstellt werden.')
}
}
setKlausuren(prev => [newKlausur, ...prev])
setForm({
title: '',
subject: 'Deutsch',
year: new Date().getFullYear(),
semester: 'Abitur',
modus: 'abitur',
})
setEhForm({
aufgabentyp: '',
titel: '',
text_titel: '',
text_autor: '',
aufgabenstellung: '',
})
setActiveTab('klausuren')
if (!error) setError(null)
} catch (err) {
console.error('Failed to create klausur:', err)
setError('Fehler beim Erstellen der Klausur')
} finally {
setCreating(false)
}
}
const handleDeleteKlausur = async (id: string) => {
if (!confirm('Klausur wirklich loeschen? Alle Studentenarbeiten werden ebenfalls geloescht.')) {
return
}
try {
const res = await fetch(`${API_BASE}/api/v1/klausuren/${id}`, {
method: 'DELETE',
})
if (res.ok) {
setKlausuren(prev => prev.filter(k => k.id !== id))
} else {
setError('Fehler beim Loeschen')
}
} catch (err) {
console.error('Failed to delete klausur:', err)
setError('Fehler beim Loeschen der Klausur')
}
}
const markAsVisited = () => {
if (typeof window !== 'undefined') {
localStorage.setItem('klausur_korrektur_visited', 'true')
}
}
const handleDirektupload = async () => {
if (direktForm.files.length === 0) {
setError('Bitte mindestens eine Arbeit hochladen')
return
}
try {
setUploading(true)
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: direktForm.klausurTitle,
subject: 'Deutsch',
year: new Date().getFullYear(),
semester: 'Vorabitur',
modus: 'vorabitur',
}),
})
if (!klausurRes.ok) {
const err = await klausurRes.json()
throw new Error(err.detail || 'Klausur erstellen fehlgeschlagen')
}
const newKlausur = await klausurRes.json()
if (direktForm.ehText.trim() || direktForm.aufgabentyp) {
const ehRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/vorabitur-eh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aufgabentyp: direktForm.aufgabentyp || 'textanalyse_pragmatisch',
titel: `EH: ${direktForm.klausurTitle}`,
aufgabenstellung: direktForm.ehText || 'Individuelle Aufgabenstellung',
}),
})
if (!ehRes.ok) {
console.error('EH creation failed, continuing with upload')
}
}
for (let i = 0; i < direktForm.files.length; i++) {
const file = direktForm.files[i]
const formData = new FormData()
formData.append('file', file)
formData.append('anonym_id', `Arbeit-${i + 1}`)
const uploadRes = await fetch(`${API_BASE}/api/v1/klausuren/${newKlausur.id}/students`, {
method: 'POST',
body: formData,
})
if (!uploadRes.ok) {
console.error(`Upload failed for file ${i + 1}:`, file.name)
}
}
setKlausuren(prev => [newKlausur, ...prev])
markAsVisited()
window.location.href = `/education/klausur-korrektur/${newKlausur.id}`
} catch (err) {
console.error('Direktupload failed:', err)
setError(err instanceof Error ? err.message : 'Fehler beim Direktupload')
} finally {
setUploading(false)
}
}
return {
// State
activeTab,
setActiveTab,
klausuren,
loading,
error,
setError,
creating,
gradeInfo,
templates,
loadingTemplates,
form,
setForm,
ehForm,
setEhForm,
direktForm,
setDirektForm,
direktStep,
setDirektStep,
uploading,
// Handlers
handleCreateKlausur,
handleDeleteKlausur,
handleDirektupload,
markAsVisited,
}
}

File diff suppressed because it is too large Load Diff