[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,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,
}
}