[split-required] Split 500-1000 LOC files across all services

backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:35:37 +02:00
parent 6811264756
commit b6983ab1dc
99 changed files with 13484 additions and 16106 deletions

View File

@@ -0,0 +1,79 @@
/**
* KorrekturDocumentViewer — center panel document display.
*
* Extracted from KorrekturPage.tsx.
*/
import { StudentKlausur } from '../services/api'
interface KorrekturDocumentViewerProps {
currentStudent: StudentKlausur | null
}
export default function KorrekturDocumentViewer({ currentStudent }: KorrekturDocumentViewerProps) {
return (
<div className="korrektur-main">
<div className="viewer-container">
<div className="viewer-toolbar">
<div style={{ fontSize: 14, fontWeight: 500 }}>
{currentStudent ? currentStudent.student_name : 'Dokument-Ansicht'}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{currentStudent && (
<>
<button className="btn btn-ghost" style={{ padding: '6px 12px' }}>
OCR-Text
</button>
<button className="btn btn-ghost" style={{ padding: '6px 12px' }}>
Original
</button>
</>
)}
</div>
</div>
<div className="viewer-content">
{!currentStudent ? (
<div className="document-placeholder">
<div className="document-placeholder-icon">{'\uD83D\uDCC4'}</div>
<div style={{ fontSize: 16, marginBottom: 8 }}>Keine Arbeit ausgewaehlt</div>
<div style={{ fontSize: 14 }}>
Waehlen Sie eine Schuelerarbeit aus der Liste oder laden Sie eine neue hoch
</div>
</div>
) : currentStudent.file_path ? (
<div className="document-viewer">
<div className="document-info-bar">
<span className="file-name">{'\uD83D\uDCC4'} {currentStudent.student_name}</span>
<span className="file-status">{'\u2713'} Hochgeladen</span>
</div>
<div className="document-frame">
{currentStudent.file_path.endsWith('.pdf') ? (
<iframe
src={`/api/v1/students/${currentStudent.id}/file`}
title="Schuelerarbeit"
style={{ width: '100%', height: '100%', minHeight: '600px' }}
/>
) : (
<img
src={`/api/v1/students/${currentStudent.id}/file`}
alt={`Arbeit von ${currentStudent.student_name}`}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
)}
</div>
</div>
) : (
<div className="document-placeholder">
<div className="document-placeholder-icon">{'\uD83D\uDCC4'}</div>
<div style={{ fontSize: 16, marginBottom: 8 }}>Keine Datei vorhanden</div>
<div style={{ fontSize: 14 }}>
Laden Sie eine Schuelerarbeit hoch, um mit der Korrektur zu beginnen.
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,197 @@
/**
* KorrekturModals — upload modal and EH prompt modal.
*
* Extracted from KorrekturPage.tsx.
*/
import { useRef, useState } from 'react'
// ---------------------------------------------------------------------------
// Upload Modal
// ---------------------------------------------------------------------------
interface UploadModalProps {
open: boolean
onClose: () => void
studentName: string
onStudentNameChange: (name: string) => void
classStudents: Array<{ id: string; name: string }>
onUpload: () => void
uploading: boolean
selectedFile: File | null
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export function UploadModal({
open,
onClose,
studentName,
onStudentNameChange,
classStudents,
onUpload,
uploading,
selectedFile,
onFileSelect,
}: UploadModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [useStudentDropdown, setUseStudentDropdown] = useState(true)
if (!open) return null
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">Schuelerarbeit hochladen</div>
<button className="modal-close" onClick={onClose}>{'\u00D7'}</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Schueler zuweisen</label>
{classStudents.length > 0 && (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input
type="checkbox"
checked={useStudentDropdown}
onChange={(e) => setUseStudentDropdown(e.target.checked)}
/>
Aus Klassenliste waehlen
</label>
</div>
)}
{useStudentDropdown && classStudents.length > 0 ? (
<select
className="input"
value={studentName}
onChange={e => onStudentNameChange(e.target.value)}
style={{ width: '100%' }}
>
<option value="">-- Schueler waehlen --</option>
{classStudents.map(s => (
<option key={s.id} value={s.name}>{s.name}</option>
))}
</select>
) : (
<input
type="text"
className="input"
placeholder="z.B. Max Mustermann"
value={studentName}
onChange={e => onStudentNameChange(e.target.value)}
/>
)}
{classStudents.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--bp-text-muted)', marginTop: 8 }}>
Keine Klassenliste verfuegbar. Bitte Namen manuell eingeben.
</p>
)}
</div>
<div className="form-group">
<label className="form-label">Datei (PDF oder Bild)</label>
<div
className={`upload-area ${selectedFile ? 'selected' : ''}`}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
style={{ display: 'none' }}
onChange={onFileSelect}
/>
{selectedFile ? (
<>
<div className="upload-icon">{'\uD83D\uDCC4'}</div>
<div className="upload-text">{selectedFile.name}</div>
</>
) : (
<>
<div className="upload-icon">{'\uD83D\uDCC1'}</div>
<div className="upload-text">
Klicken Sie hier oder ziehen Sie eine Datei hinein
</div>
</>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={onClose}>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={!studentName || !selectedFile || uploading}
onClick={onUpload}
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// EH Prompt Modal
// ---------------------------------------------------------------------------
interface EHPromptModalProps {
open: boolean
onUpload: () => void
onDismiss: () => void
}
export function EHPromptModal({ open, onUpload, onDismiss }: EHPromptModalProps) {
if (!open) return null
return (
<div className="modal-overlay" onClick={onDismiss}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 500 }}>
<div className="modal-header">
<div className="modal-title">{'\uD83D\uDCCB'} Erwartungshorizont hochladen?</div>
<button className="modal-close" onClick={onDismiss}>{'\u00D7'}</button>
</div>
<div className="modal-body">
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
Sie haben die erste Schuelerarbeit hochgeladen. Moechten Sie jetzt einen
<strong> Erwartungshorizont</strong> hinzufuegen?
</p>
<div style={{
background: 'var(--bp-bg-light)',
border: '1px solid var(--bp-border)',
borderRadius: 8,
padding: 16,
marginBottom: 16
}}>
<div style={{ fontWeight: 500, marginBottom: 8 }}>{'\u2713'} Vorteile:</div>
<ul style={{ margin: 0, paddingLeft: 20, color: 'var(--bp-text-muted)', fontSize: 14, lineHeight: 1.8 }}>
<li>KI-gestuetzte Korrekturvorschlaege basierend auf Ihrem EH</li>
<li>Bessere und konsistentere Bewertungen</li>
<li>Automatisch fuer alle Korrektoren verfuegbar</li>
<li>Ende-zu-Ende verschluesselt - nur Sie haben den Schluessel</li>
</ul>
</div>
<p style={{ fontSize: 13, color: 'var(--bp-text-muted)' }}>
Sie koennen den Erwartungshorizont auch spaeter hochladen.
</p>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={onDismiss}>
Spaeter
</button>
<button className="btn btn-primary" onClick={onUpload}>
Jetzt hochladen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,101 @@
/**
* KorrekturSidebar — collapsible left sidebar with klausur info and student list.
*
* Extracted from KorrekturPage.tsx.
*/
import { StudentKlausur } from '../services/api'
interface KorrekturSidebarProps {
collapsed: boolean
onToggle: () => void
klausurTitle: string
klausurModus: string
students: StudentKlausur[]
currentStudentId?: string
onSelectStudent: (id: string) => void
onDeleteStudent: (id: string, e: React.MouseEvent) => void
onUploadClick: () => void
}
export default function KorrekturSidebar({
collapsed,
onToggle,
klausurTitle,
klausurModus,
students,
currentStudentId,
onSelectStudent,
onDeleteStudent,
onUploadClick,
}: KorrekturSidebarProps) {
return (
<div className={`korrektur-sidebar ${collapsed ? 'collapsed' : ''}`}>
<button
className="sidebar-toggle"
onClick={onToggle}
title={collapsed ? 'Sidebar einblenden' : 'Sidebar ausblenden'}
>
{collapsed ? '\u2192' : '\u2190'}
</button>
{!collapsed && (
<>
<div className="sidebar-section">
<div className="sidebar-section-title">Klausur</div>
<div className="klausur-item active">
<div className="klausur-icon">{'\uD83D\uDCCB'}</div>
<div className="klausur-info">
<div className="klausur-name">{klausurTitle}</div>
<div className="klausur-meta">
{klausurModus === 'landes_abitur' ? 'Abitur' : 'Vorabitur'} {'\u2022'} {students.length} Schueler
</div>
</div>
</div>
</div>
<div className="sidebar-section" style={{ flex: 1 }}>
<div className="sidebar-section-title">Schuelerarbeiten</div>
{students.length === 0 ? (
<div style={{ color: 'var(--bp-text-muted)', fontSize: 13, padding: '8px 0' }}>
Noch keine Arbeiten hochgeladen
</div>
) : (
students.map((student) => (
<div
key={student.id}
className={`klausur-item ${currentStudentId === student.id ? 'active' : ''}`}
onClick={() => onSelectStudent(student.id)}
>
<div className="klausur-icon">{'\uD83D\uDCC4'}</div>
<div className="klausur-info">
<div className="klausur-name">{student.student_name}</div>
<div className="klausur-meta">
{student.status === 'completed' ? `${student.grade_points} Punkte` : student.status}
</div>
</div>
<button
className="delete-btn"
onClick={(e) => onDeleteStudent(student.id, e)}
title="Loeschen"
>
{'\uD83D\uDDD1\uFE0F'}
</button>
</div>
))
)}
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 16 }}
onClick={onUploadClick}
>
+ Arbeit hochladen
</button>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,285 @@
/**
* KorrekturWizardSteps — right panel wizard content for korrektur, bewertung, gutachten.
*
* Extracted from KorrekturPage.tsx.
*/
import { StudentKlausur } from '../services/api'
import { CRITERIA, GRADE_LABELS, WizardStep } from '../pages/KorrekturConstants'
interface WizardStepIndicatorProps {
current: WizardStep
}
function WizardStepIndicator({ current }: WizardStepIndicatorProps) {
const steps: { key: WizardStep; label: string; number: string }[] = [
{ key: 'korrektur', label: 'Korrektur', number: '1' },
{ key: 'bewertung', label: 'Bewertung', number: '2' },
{ key: 'gutachten', label: 'Gutachten', number: '3' },
]
const currentIdx = steps.findIndex(s => s.key === current)
return (
<div className="wizard-steps">
{steps.map((step, idx) => {
const isCompleted = idx < currentIdx
const isActive = idx === currentIdx
return (
<div key={step.key} className={`wizard-step ${isCompleted ? 'completed' : ''} ${isActive ? 'active' : ''}`}>
<span className="wizard-step-number">{isCompleted ? '\u2713' : step.number}</span>
<span className="wizard-step-label">{step.label}</span>
</div>
)
})}
</div>
)
}
interface KorrekturStepProps {
korrekturNotes: string
onNotesChange: (notes: string) => void
onComplete: () => void
}
export function KorrekturStep({ korrekturNotes, onNotesChange, onComplete }: KorrekturStepProps) {
return (
<>
<WizardStepIndicator current="korrektur" />
<div className="panel-section">
<div className="panel-section-title">{'\u270F\uFE0F'} Korrektur durchfuehren</div>
<p style={{ color: 'var(--bp-text-muted)', fontSize: 13, marginBottom: 16 }}>
Lesen Sie die Arbeit sorgfaeltig und machen Sie Anmerkungen direkt im Dokument.
Notieren Sie hier Ihre wichtigsten Beobachtungen.
</p>
<div className="form-group">
<label className="form-label">Korrektur-Notizen</label>
<textarea
className="textarea"
placeholder="Ihre Notizen waehrend der Korrektur..."
style={{ minHeight: 200 }}
value={korrekturNotes}
onChange={(e) => onNotesChange(e.target.value)}
/>
</div>
</div>
<div className="panel-section">
<button
className="btn btn-primary"
style={{ width: '100%' }}
onClick={onComplete}
>
Weiter zur Bewertung {'\u2192'}
</button>
</div>
</>
)
}
interface BewertungStepProps {
gradePoints: number
totalPercentage: number
localScores: Record<string, number>
savingCriteria: boolean
allCriteriaFilled: boolean
onCriteriaChange: (criterion: string, value: number) => void
onBack: () => void
onComplete: () => void
}
export function BewertungStep({
gradePoints,
totalPercentage,
localScores,
savingCriteria,
allCriteriaFilled,
onCriteriaChange,
onBack,
onComplete,
}: BewertungStepProps) {
return (
<>
<WizardStepIndicator current="bewertung" />
<div className="panel-section">
<div className="panel-section-title">{'\uD83D\uDCCA'} Gesamtnote</div>
<div className="grade-display">
<div className="grade-points">{gradePoints}</div>
<div className="grade-label">
{GRADE_LABELS[gradePoints]} ({totalPercentage}%)
</div>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title">
{'\u270F\uFE0F'} Bewertungskriterien
{savingCriteria && <span style={{ fontSize: 11, color: 'var(--bp-text-muted)' }}> (Speichert...)</span>}
</div>
{CRITERIA.map(c => (
<div key={c.key} className="criterion-item">
<div className="criterion-header">
<span className="criterion-label">{c.label} ({Math.round(c.weight * 100)}%)</span>
<span className="criterion-score">{localScores[c.key] || 0}%</span>
</div>
<input
type="range"
className="criterion-slider"
min="0"
max="100"
value={localScores[c.key] || 0}
onChange={(e) => onCriteriaChange(c.key, Number(e.target.value))}
/>
</div>
))}
</div>
<div className="panel-section">
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={onBack}
>
{'\u2190'} Zurueck
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={onComplete}
disabled={!allCriteriaFilled}
>
Weiter {'\u2192'}
</button>
</div>
{!allCriteriaFilled && (
<p style={{ color: 'var(--bp-warning)', fontSize: 12, marginTop: 8, textAlign: 'center' }}>
Bitte alle Kriterien bewerten
</p>
)}
</div>
</>
)
}
interface GutachtenStepProps {
gradePoints: number
currentStudent: StudentKlausur
localGutachten: { einleitung: string; hauptteil: string; fazit: string }
generatingGutachten: boolean
savingGutachten: boolean
finalizingStudent: boolean
onGutachtenChange: (field: 'einleitung' | 'hauptteil' | 'fazit', value: string) => void
onGenerate: () => void
onSave: () => void
onFinalize: () => void
onBack: () => void
}
export function GutachtenStep({
gradePoints,
currentStudent,
localGutachten,
generatingGutachten,
savingGutachten,
finalizingStudent,
onGutachtenChange,
onGenerate,
onSave,
onFinalize,
onBack,
}: GutachtenStepProps) {
return (
<>
<WizardStepIndicator current="gutachten" />
<div className="panel-section">
<div className="panel-section-title">
{'\uD83D\uDCCA'} Endergebnis: {gradePoints} Punkte ({GRADE_LABELS[gradePoints]})
</div>
</div>
<div className="panel-section">
<div className="panel-section-title">
{'\uD83D\uDCDD'} Gutachten
{savingGutachten && <span style={{ fontSize: 11, color: 'var(--bp-text-muted)' }}> (Speichert...)</span>}
</div>
<button
className="btn btn-secondary"
style={{ width: '100%', marginBottom: 16 }}
onClick={onGenerate}
disabled={generatingGutachten}
>
{generatingGutachten ? '\u231B KI generiert...' : '\uD83E\uDD16 KI-Gutachten generieren'}
</button>
<div className="form-group">
<label className="form-label">Einleitung</label>
<textarea
className="textarea"
placeholder="Allgemeine Einordnung der Arbeit..."
value={localGutachten.einleitung}
onChange={(e) => onGutachtenChange('einleitung', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">Hauptteil</label>
<textarea
className="textarea"
placeholder="Detaillierte Bewertung..."
style={{ minHeight: 120 }}
value={localGutachten.hauptteil}
onChange={(e) => onGutachtenChange('hauptteil', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">Fazit</label>
<textarea
className="textarea"
placeholder="Zusammenfassung und Empfehlungen..."
value={localGutachten.fazit}
onChange={(e) => onGutachtenChange('fazit', e.target.value)}
/>
</div>
<button
className="btn btn-secondary"
style={{ width: '100%', marginBottom: 8 }}
onClick={onSave}
disabled={savingGutachten}
>
{savingGutachten ? '\uD83D\uDCBE Speichert...' : '\uD83D\uDCBE Gutachten speichern'}
</button>
</div>
<div className="panel-section">
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={onBack}
>
{'\u2190'} Zurueck
</button>
</div>
<button
className="btn btn-primary"
style={{ width: '100%' }}
onClick={onFinalize}
disabled={finalizingStudent || currentStudent.status === 'completed'}
>
{currentStudent.status === 'completed'
? '\u2713 Abgeschlossen'
: finalizingStudent
? 'Wird abgeschlossen...'
: '\u2713 Bewertung abschliessen'}
</button>
</div>
</>
)
}

View File

@@ -0,0 +1,37 @@
/**
* KorrekturPage Constants — grade tables, criteria definitions, types.
*
* Extracted from KorrekturPage.tsx.
*/
// Grade calculation
export const GRADE_THRESHOLDS: Record<number, number> = {
15: 95, 14: 90, 13: 85, 12: 80, 11: 75, 10: 70,
9: 65, 8: 60, 7: 55, 6: 50, 5: 45, 4: 40,
3: 33, 2: 27, 1: 20, 0: 0
}
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 CRITERIA = [
{ key: 'inhalt', label: 'Inhaltliche Leistung', weight: 0.40 },
{ key: 'struktur', label: 'Aufbau & Struktur', weight: 0.15 },
{ key: 'stil', label: 'Ausdruck & Stil', weight: 0.15 },
{ key: 'grammatik', label: 'Grammatik', weight: 0.15 },
{ key: 'rechtschreibung', label: 'Rechtschreibung', weight: 0.15 }
] as const
export type WizardStep = 'korrektur' | 'bewertung' | 'gutachten'
export function calculateGradePoints(percentage: number): number {
for (const [points, threshold] of Object.entries(GRADE_THRESHOLDS).sort((a, b) => Number(b[0]) - Number(a[0]))) {
if (percentage >= threshold) {
return Number(points)
}
}
return 0
}

File diff suppressed because it is too large Load Diff