Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3b9617fc3 | |||
| 8efffe8c52 | |||
| a317bd6164 |
@@ -4,13 +4,14 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY admin-lehrer/package.json admin-lehrer/package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy source code + shared types
|
||||
COPY admin-lehrer/ .
|
||||
COPY shared/ /shared/
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available OCR Models
|
||||
*
|
||||
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||
*/
|
||||
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||
|
||||
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||
'llama3.2-vision:11b': {
|
||||
label: 'Vision LLM',
|
||||
description: 'Beste Qualitaet bei Handschrift',
|
||||
speed: 'langsam',
|
||||
},
|
||||
trocr: {
|
||||
label: 'Microsoft TrOCR',
|
||||
description: 'Schnell bei gedrucktem Text',
|
||||
speed: 'schnell',
|
||||
},
|
||||
paddleocr: {
|
||||
label: 'PaddleOCR + LLM',
|
||||
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||
speed: 'sehr schnell',
|
||||
},
|
||||
donut: {
|
||||
label: 'Donut',
|
||||
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||
speed: 'mittel',
|
||||
},
|
||||
}
|
||||
|
||||
export interface OCRSession {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRItem {
|
||||
id: string
|
||||
session_id: string
|
||||
session_name: string
|
||||
image_path: string
|
||||
image_url?: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
ground_truth?: string
|
||||
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRStats {
|
||||
total_sessions?: number
|
||||
session_id?: string
|
||||
name?: string
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items?: number
|
||||
pending_items: number
|
||||
exportable_items?: number
|
||||
accuracy_rate: number
|
||||
avg_label_time_seconds?: number
|
||||
progress_percent?: number
|
||||
}
|
||||
|
||||
export interface TrainingSample {
|
||||
id: string
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
training_batch: string
|
||||
exported_at?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
item_id: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface CorrectRequest {
|
||||
item_id: string
|
||||
ground_truth: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
session_id?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
filename: string
|
||||
image_path: string
|
||||
image_hash: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
status: string
|
||||
}
|
||||
export * from '@shared/types/ocr-labeling'
|
||||
|
||||
+16
-85
@@ -1,93 +1,24 @@
|
||||
/**
|
||||
* Types and constants for the Korrektur-Workspace page.
|
||||
*
|
||||
* Domain types are re-exported from the shared module.
|
||||
* Only the API_BASE constant remains local (uses Next.js rewrite proxy).
|
||||
*/
|
||||
|
||||
import type { CriteriaScores } from '../../../types'
|
||||
export type {
|
||||
ExaminerInfo,
|
||||
ExaminerResult,
|
||||
ExaminerWorkflow,
|
||||
ActiveTab,
|
||||
GradeTotals,
|
||||
CriteriaScores,
|
||||
} from '@shared/types/klausur'
|
||||
|
||||
// ---- 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 ----
|
||||
export {
|
||||
WORKFLOW_STATUS_LABELS,
|
||||
ROLE_LABELS,
|
||||
GRADE_LABELS,
|
||||
} from '@shared/types/klausur'
|
||||
|
||||
/** 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' },
|
||||
}
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
/**
|
||||
* Local form types for Klausur-Korrektur page
|
||||
* Local form types for Klausur-Korrektur page.
|
||||
* Re-exported from the shared module.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
export type {
|
||||
CreateKlausurForm,
|
||||
VorabiturEHForm,
|
||||
EHTemplate,
|
||||
DirektuploadForm,
|
||||
} from '@shared/types/klausur'
|
||||
|
||||
@@ -1,195 +1 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
export * from '@shared/types/klausur'
|
||||
|
||||
@@ -1,329 +1 @@
|
||||
/**
|
||||
* TypeScript Types for Companion Module
|
||||
* Migration from Flask companion.py/companion_js.py
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Phase System
|
||||
// ============================================================================
|
||||
|
||||
export type PhaseId = 'einstieg' | 'erarbeitung' | 'sicherung' | 'transfer' | 'reflexion'
|
||||
|
||||
export interface Phase {
|
||||
id: PhaseId
|
||||
shortName: string // E, A, S, T, R
|
||||
displayName: string
|
||||
duration: number // minutes
|
||||
status: 'planned' | 'active' | 'completed'
|
||||
actualTime?: number // seconds (actual time spent)
|
||||
color: string // hex color
|
||||
}
|
||||
|
||||
export interface PhaseContext {
|
||||
currentPhase: PhaseId
|
||||
phaseDisplayName: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard / Companion Mode
|
||||
// ============================================================================
|
||||
|
||||
export interface CompanionStats {
|
||||
classesCount: number
|
||||
studentsCount: number
|
||||
learningUnitsCreated: number
|
||||
gradesEntered: number
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
percentage: number
|
||||
completed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type SuggestionPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
priority: SuggestionPriority
|
||||
icon: string // lucide icon name
|
||||
actionTarget: string // navigation path
|
||||
estimatedTime: number // minutes
|
||||
}
|
||||
|
||||
export type EventType = 'exam' | 'parent_meeting' | 'deadline' | 'other'
|
||||
|
||||
export interface UpcomingEvent {
|
||||
id: string
|
||||
title: string
|
||||
date: string // ISO date string
|
||||
type: EventType
|
||||
inDays: number
|
||||
}
|
||||
|
||||
export interface CompanionData {
|
||||
context: PhaseContext
|
||||
stats: CompanionStats
|
||||
phases: Phase[]
|
||||
progress: Progress
|
||||
suggestions: Suggestion[]
|
||||
upcomingEvents: UpcomingEvent[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Mode
|
||||
// ============================================================================
|
||||
|
||||
export type LessonStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'overtime'
|
||||
|
||||
export interface LessonPhase {
|
||||
phase: PhaseId
|
||||
duration: number // planned duration in minutes
|
||||
status: 'planned' | 'active' | 'completed' | 'skipped'
|
||||
actualTime: number // actual time spent in seconds
|
||||
startedAt?: string // ISO timestamp
|
||||
completedAt?: string // ISO timestamp
|
||||
}
|
||||
|
||||
export interface Homework {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
dueDate: string // ISO date
|
||||
attachments?: string[]
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export interface Material {
|
||||
id: string
|
||||
title: string
|
||||
type: 'document' | 'video' | 'presentation' | 'link' | 'other'
|
||||
url?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface LessonReflection {
|
||||
rating: number // 1-5 stars
|
||||
notes: string
|
||||
nextSteps: string
|
||||
savedAt?: string
|
||||
}
|
||||
|
||||
export interface LessonSession {
|
||||
sessionId: string
|
||||
classId: string
|
||||
className: string
|
||||
subject: string
|
||||
topic?: string
|
||||
startTime: string // ISO timestamp
|
||||
endTime?: string // ISO timestamp
|
||||
phases: LessonPhase[]
|
||||
totalPlannedDuration: number // minutes
|
||||
currentPhaseIndex: number
|
||||
elapsedTime: number // seconds
|
||||
isPaused: boolean
|
||||
pausedAt?: string
|
||||
pauseDuration: number // total pause time in seconds
|
||||
overtimeMinutes: number
|
||||
status: LessonStatus
|
||||
homeworkList: Homework[]
|
||||
materials: Material[]
|
||||
reflection?: LessonReflection
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Templates
|
||||
// ============================================================================
|
||||
|
||||
export interface PhaseDurations {
|
||||
einstieg: number
|
||||
erarbeitung: number
|
||||
sicherung: number
|
||||
transfer: number
|
||||
reflexion: number
|
||||
}
|
||||
|
||||
export interface LessonTemplate {
|
||||
templateId: string
|
||||
name: string
|
||||
description?: string
|
||||
subject?: string
|
||||
durations: PhaseDurations
|
||||
isSystemTemplate: boolean
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings
|
||||
// ============================================================================
|
||||
|
||||
export interface TeacherSettings {
|
||||
defaultPhaseDurations: PhaseDurations
|
||||
preferredLessonLength: number // minutes (default 45)
|
||||
autoAdvancePhases: boolean
|
||||
soundNotifications: boolean
|
||||
showKeyboardShortcuts: boolean
|
||||
highContrastMode: boolean
|
||||
onboardingCompleted: boolean
|
||||
selectedTemplateId?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timer State
|
||||
// ============================================================================
|
||||
|
||||
export type TimerColorStatus = 'plenty' | 'warning' | 'critical' | 'overtime'
|
||||
|
||||
export interface TimerState {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
elapsedSeconds: number
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
progress: number // 0-1
|
||||
colorStatus: TimerColorStatus
|
||||
currentPhase: LessonPhase | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Forms
|
||||
// ============================================================================
|
||||
|
||||
export interface LessonStartFormData {
|
||||
classId: string
|
||||
subject: string
|
||||
topic?: string
|
||||
templateId?: string
|
||||
customDurations?: PhaseDurations
|
||||
}
|
||||
|
||||
export interface Class {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feedback
|
||||
// ============================================================================
|
||||
|
||||
export type FeedbackType = 'bug' | 'feature' | 'feedback'
|
||||
|
||||
export interface FeedbackSubmission {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
screenshot?: string // base64
|
||||
sessionId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Onboarding
|
||||
// ============================================================================
|
||||
|
||||
export interface OnboardingStep {
|
||||
step: number
|
||||
title: string
|
||||
description: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
steps: OnboardingStep[]
|
||||
selectedState?: string // Bundesland
|
||||
selectedSchoolType?: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Messages
|
||||
// ============================================================================
|
||||
|
||||
export type WSMessageType =
|
||||
| 'phase_update'
|
||||
| 'timer_tick'
|
||||
| 'overtime_warning'
|
||||
| 'pause_toggle'
|
||||
| 'session_end'
|
||||
| 'sync_request'
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType
|
||||
payload: {
|
||||
sessionId: string
|
||||
phase?: number
|
||||
elapsed?: number
|
||||
isPaused?: boolean
|
||||
overtimeMinutes?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Responses
|
||||
// ============================================================================
|
||||
|
||||
export interface APIResponse<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DashboardResponse extends APIResponse<CompanionData> {}
|
||||
|
||||
export interface LessonResponse extends APIResponse<LessonSession> {}
|
||||
|
||||
export interface TemplatesResponse extends APIResponse<{ templates: LessonTemplate[] }> {}
|
||||
|
||||
export interface SettingsResponse extends APIResponse<TeacherSettings> {}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export type CompanionMode = 'companion' | 'lesson' | 'classic'
|
||||
|
||||
export interface ModeToggleProps {
|
||||
currentMode: CompanionMode
|
||||
onModeChange: (mode: CompanionMode) => void
|
||||
}
|
||||
|
||||
export interface PhaseTimelineProps {
|
||||
phases: Phase[]
|
||||
currentPhaseIndex: number
|
||||
onPhaseClick?: (index: number) => void
|
||||
}
|
||||
|
||||
export interface VisualPieTimerProps {
|
||||
progress: number // 0-1
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
currentPhaseName: string
|
||||
phaseColor: string
|
||||
}
|
||||
|
||||
export interface QuickActionsBarProps {
|
||||
onExtend: (minutes: number) => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onSkip: () => void
|
||||
isPaused: boolean
|
||||
isLastPhase: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
export * from '@shared/types/companion'
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"../shared/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
|
||||
+6
-6
@@ -60,8 +60,8 @@ services:
|
||||
# =========================================================
|
||||
admin-lehrer:
|
||||
build:
|
||||
context: ./admin-lehrer
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: admin-lehrer/Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://macmini:8001}
|
||||
NEXT_PUBLIC_OLD_ADMIN_URL: ${NEXT_PUBLIC_OLD_ADMIN_URL:-http://macmini:3000/admin}
|
||||
@@ -95,8 +95,8 @@ services:
|
||||
|
||||
studio-v2:
|
||||
build:
|
||||
context: ./studio-v2
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: studio-v2/Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_VOICE_SERVICE_URL: ${NEXT_PUBLIC_VOICE_SERVICE_URL:-wss://macmini:8091}
|
||||
NEXT_PUBLIC_KLAUSUR_SERVICE_URL: ${NEXT_PUBLIC_KLAUSUR_SERVICE_URL:-https://macmini:8086}
|
||||
@@ -116,8 +116,8 @@ services:
|
||||
|
||||
website:
|
||||
build:
|
||||
context: ./website
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: website/Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BILLING_API_URL: ${NEXT_PUBLIC_BILLING_API_URL:-https://macmini:8083}
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://macmini}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* TypeScript Types for Companion Module
|
||||
* Migration from Flask companion.py/companion_js.py
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Phase System
|
||||
// ============================================================================
|
||||
|
||||
export type PhaseId = 'einstieg' | 'erarbeitung' | 'sicherung' | 'transfer' | 'reflexion'
|
||||
|
||||
export interface Phase {
|
||||
id: PhaseId
|
||||
shortName: string // E, A, S, T, R
|
||||
displayName: string
|
||||
duration: number // minutes
|
||||
status: 'planned' | 'active' | 'completed'
|
||||
actualTime?: number // seconds (actual time spent)
|
||||
color: string // hex color
|
||||
}
|
||||
|
||||
export interface PhaseContext {
|
||||
currentPhase: PhaseId
|
||||
phaseDisplayName: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard / Companion Mode
|
||||
// ============================================================================
|
||||
|
||||
export interface CompanionStats {
|
||||
classesCount: number
|
||||
studentsCount: number
|
||||
learningUnitsCreated: number
|
||||
gradesEntered: number
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
percentage: number
|
||||
completed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type SuggestionPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
priority: SuggestionPriority
|
||||
icon: string // lucide icon name
|
||||
actionTarget: string // navigation path
|
||||
estimatedTime: number // minutes
|
||||
}
|
||||
|
||||
export type EventType = 'exam' | 'parent_meeting' | 'deadline' | 'other'
|
||||
|
||||
export interface UpcomingEvent {
|
||||
id: string
|
||||
title: string
|
||||
date: string // ISO date string
|
||||
type: EventType
|
||||
inDays: number
|
||||
}
|
||||
|
||||
export interface CompanionData {
|
||||
context: PhaseContext
|
||||
stats: CompanionStats
|
||||
phases: Phase[]
|
||||
progress: Progress
|
||||
suggestions: Suggestion[]
|
||||
upcomingEvents: UpcomingEvent[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Mode
|
||||
// ============================================================================
|
||||
|
||||
export type LessonStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'overtime'
|
||||
|
||||
export interface LessonPhase {
|
||||
phase: PhaseId
|
||||
duration: number // planned duration in minutes
|
||||
status: 'planned' | 'active' | 'completed' | 'skipped'
|
||||
actualTime: number // actual time spent in seconds
|
||||
startedAt?: string // ISO timestamp
|
||||
completedAt?: string // ISO timestamp
|
||||
}
|
||||
|
||||
export interface Homework {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
dueDate: string // ISO date
|
||||
attachments?: string[]
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export interface Material {
|
||||
id: string
|
||||
title: string
|
||||
type: 'document' | 'video' | 'presentation' | 'link' | 'other'
|
||||
url?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface LessonReflection {
|
||||
rating: number // 1-5 stars
|
||||
notes: string
|
||||
nextSteps: string
|
||||
savedAt?: string
|
||||
}
|
||||
|
||||
export interface LessonSession {
|
||||
sessionId: string
|
||||
classId: string
|
||||
className: string
|
||||
subject: string
|
||||
topic?: string
|
||||
startTime: string // ISO timestamp
|
||||
endTime?: string // ISO timestamp
|
||||
phases: LessonPhase[]
|
||||
totalPlannedDuration: number // minutes
|
||||
currentPhaseIndex: number
|
||||
elapsedTime: number // seconds
|
||||
isPaused: boolean
|
||||
pausedAt?: string
|
||||
pauseDuration: number // total pause time in seconds
|
||||
overtimeMinutes: number
|
||||
status: LessonStatus
|
||||
homeworkList: Homework[]
|
||||
materials: Material[]
|
||||
reflection?: LessonReflection
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Templates
|
||||
// ============================================================================
|
||||
|
||||
export interface PhaseDurations {
|
||||
einstieg: number
|
||||
erarbeitung: number
|
||||
sicherung: number
|
||||
transfer: number
|
||||
reflexion: number
|
||||
}
|
||||
|
||||
export interface LessonTemplate {
|
||||
templateId: string
|
||||
name: string
|
||||
description?: string
|
||||
subject?: string
|
||||
durations: PhaseDurations
|
||||
isSystemTemplate: boolean
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings
|
||||
// ============================================================================
|
||||
|
||||
export interface TeacherSettings {
|
||||
defaultPhaseDurations: PhaseDurations
|
||||
preferredLessonLength: number // minutes (default 45)
|
||||
autoAdvancePhases: boolean
|
||||
soundNotifications: boolean
|
||||
showKeyboardShortcuts: boolean
|
||||
highContrastMode: boolean
|
||||
onboardingCompleted: boolean
|
||||
selectedTemplateId?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timer State
|
||||
// ============================================================================
|
||||
|
||||
export type TimerColorStatus = 'plenty' | 'warning' | 'critical' | 'overtime'
|
||||
|
||||
export interface TimerState {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
elapsedSeconds: number
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
progress: number // 0-1
|
||||
colorStatus: TimerColorStatus
|
||||
currentPhase: LessonPhase | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Forms
|
||||
// ============================================================================
|
||||
|
||||
export interface LessonStartFormData {
|
||||
classId: string
|
||||
subject: string
|
||||
topic?: string
|
||||
templateId?: string
|
||||
customDurations?: PhaseDurations
|
||||
}
|
||||
|
||||
export interface Class {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feedback
|
||||
// ============================================================================
|
||||
|
||||
export type FeedbackType = 'bug' | 'feature' | 'feedback'
|
||||
|
||||
export interface FeedbackSubmission {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
screenshot?: string // base64
|
||||
sessionId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Onboarding
|
||||
// ============================================================================
|
||||
|
||||
export interface OnboardingStep {
|
||||
step: number
|
||||
title: string
|
||||
description: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
steps: OnboardingStep[]
|
||||
selectedState?: string // Bundesland
|
||||
selectedSchoolType?: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Messages
|
||||
// ============================================================================
|
||||
|
||||
export type WSMessageType =
|
||||
| 'phase_update'
|
||||
| 'timer_tick'
|
||||
| 'overtime_warning'
|
||||
| 'pause_toggle'
|
||||
| 'session_end'
|
||||
| 'sync_request'
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType
|
||||
payload: {
|
||||
sessionId: string
|
||||
phase?: number
|
||||
elapsed?: number
|
||||
isPaused?: boolean
|
||||
overtimeMinutes?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Responses
|
||||
// ============================================================================
|
||||
|
||||
export interface APIResponse<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DashboardResponse extends APIResponse<CompanionData> {}
|
||||
|
||||
export interface LessonResponse extends APIResponse<LessonSession> {}
|
||||
|
||||
export interface TemplatesResponse extends APIResponse<{ templates: LessonTemplate[] }> {}
|
||||
|
||||
export interface SettingsResponse extends APIResponse<TeacherSettings> {}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export type CompanionMode = 'companion' | 'lesson' | 'classic'
|
||||
|
||||
export interface ModeToggleProps {
|
||||
currentMode: CompanionMode
|
||||
onModeChange: (mode: CompanionMode) => void
|
||||
}
|
||||
|
||||
export interface PhaseTimelineProps {
|
||||
phases: Phase[]
|
||||
currentPhaseIndex: number
|
||||
onPhaseClick?: (index: number) => void
|
||||
}
|
||||
|
||||
export interface VisualPieTimerProps {
|
||||
progress: number // 0-1
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
currentPhaseName: string
|
||||
phaseColor: string
|
||||
}
|
||||
|
||||
export interface QuickActionsBarProps {
|
||||
onExtend: (minutes: number) => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onSkip: () => void
|
||||
isPaused: boolean
|
||||
isLastPhase: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './companion'
|
||||
export * from './klausur'
|
||||
export * from './ocr-labeling'
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Shared Klausur-Korrektur types and constants.
|
||||
*
|
||||
* This is the single source of truth used by:
|
||||
* - admin-lehrer (education/klausur-korrektur)
|
||||
* - studio-v2 (korrektur)
|
||||
* - website/admin (klausur-korrektur)
|
||||
* - website/lehrer (klausur-korrektur)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core domain interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: KlausurModus
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
/** Union of all modus values used across services */
|
||||
export type KlausurModus = 'abitur' | 'vorabitur' | 'landes_abitur'
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fairness analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EH suggestions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
// Attribution fields (CTRL-SRC-002)
|
||||
source_document?: string
|
||||
source_url?: string
|
||||
license?: string
|
||||
license_url?: string
|
||||
publisher?: string
|
||||
}
|
||||
|
||||
/** Default Attribution for NiBiS documents (CTRL-SRC-002) */
|
||||
export const NIBIS_ATTRIBUTION = {
|
||||
publisher: 'Niedersaechsischer Bildungsserver (NiBiS)',
|
||||
license: 'DL-DE-BY-2.0',
|
||||
license_url: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
source_url: 'https://nibis.de',
|
||||
} as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutachten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create / update types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateKlausurData {
|
||||
title: string
|
||||
subject?: string
|
||||
year?: number
|
||||
semester?: string
|
||||
modus?: KlausurModus
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — annotation colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — status colors & labels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — criteria & grades
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default criteria with weights (Niedersachsen standard) */
|
||||
export const DEFAULT_CRITERIA: Record<string, { name: string; weight: number }> = {
|
||||
rechtschreibung: { name: 'Rechtschreibung', weight: 15 },
|
||||
grammatik: { name: 'Grammatik', weight: 15 },
|
||||
inhalt: { name: 'Inhalt', weight: 40 },
|
||||
struktur: { name: 'Struktur', weight: 15 },
|
||||
stil: { name: 'Stil', weight: 15 },
|
||||
}
|
||||
|
||||
/** Grade thresholds (15-point system) */
|
||||
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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Calculate grade points from a percentage (0-100). */
|
||||
export function calculateGrade(percentage: number): number {
|
||||
for (const [grade, threshold] of Object.entries(GRADE_THRESHOLDS).sort(
|
||||
(a, b) => Number(b[0]) - Number(a[0]),
|
||||
)) {
|
||||
if (percentage >= threshold) {
|
||||
return Number(grade)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Human-readable label for a 15-point grade value. */
|
||||
export function getGradeLabel(points: number): string {
|
||||
const labels: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-',
|
||||
12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-',
|
||||
6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-',
|
||||
0: '6',
|
||||
}
|
||||
return labels[points] || String(points)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Examiner workflow types (workspace)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
|
||||
|
||||
export interface GradeTotals {
|
||||
raw: number
|
||||
weighted: number
|
||||
gradePoints: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — workflow status & roles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form types (create / upload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Shared TypeScript types for OCR Labeling UI.
|
||||
*
|
||||
* Single source of truth used by:
|
||||
* - admin-lehrer (ai/ocr-labeling)
|
||||
* - website (admin/ocr-labeling)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available OCR Models
|
||||
*
|
||||
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||
*/
|
||||
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||
|
||||
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||
'llama3.2-vision:11b': {
|
||||
label: 'Vision LLM',
|
||||
description: 'Beste Qualitaet bei Handschrift',
|
||||
speed: 'langsam',
|
||||
},
|
||||
trocr: {
|
||||
label: 'Microsoft TrOCR',
|
||||
description: 'Schnell bei gedrucktem Text',
|
||||
speed: 'schnell',
|
||||
},
|
||||
paddleocr: {
|
||||
label: 'PaddleOCR + LLM',
|
||||
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||
speed: 'sehr schnell',
|
||||
},
|
||||
donut: {
|
||||
label: 'Donut',
|
||||
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||
speed: 'mittel',
|
||||
},
|
||||
}
|
||||
|
||||
export interface OCRSession {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRItem {
|
||||
id: string
|
||||
session_id: string
|
||||
session_name: string
|
||||
image_path: string
|
||||
image_url?: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
ground_truth?: string
|
||||
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRStats {
|
||||
total_sessions?: number
|
||||
session_id?: string
|
||||
name?: string
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items?: number
|
||||
pending_items: number
|
||||
exportable_items?: number
|
||||
accuracy_rate: number
|
||||
avg_label_time_seconds?: number
|
||||
progress_percent?: number
|
||||
}
|
||||
|
||||
export interface TrainingSample {
|
||||
id: string
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
training_batch: string
|
||||
exported_at?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
item_id: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface CorrectRequest {
|
||||
item_id: string
|
||||
ground_truth: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
session_id?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
filename: string
|
||||
image_path: string
|
||||
image_hash: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
status: string
|
||||
}
|
||||
@@ -4,13 +4,14 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY studio-v2/package.json studio-v2/package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
# Copy source files + shared types
|
||||
COPY studio-v2/ .
|
||||
COPY shared/ /shared/
|
||||
|
||||
# Build arguments for environment variables (needed at build time for Next.js)
|
||||
ARG NEXT_PUBLIC_VOICE_SERVICE_URL
|
||||
|
||||
@@ -1,257 +1 @@
|
||||
// TypeScript Interfaces fuer Korrekturplattform (Studio v2)
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'landes_abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
// Attribution fields (CTRL-SRC-002)
|
||||
source_document?: string
|
||||
source_url?: string
|
||||
license?: string
|
||||
license_url?: string
|
||||
publisher?: string
|
||||
}
|
||||
|
||||
// Default Attribution for NiBiS documents (CTRL-SRC-002)
|
||||
export const NIBIS_ATTRIBUTION = {
|
||||
publisher: 'Niedersaechsischer Bildungsserver (NiBiS)',
|
||||
license: 'DL-DE-BY-2.0',
|
||||
license_url: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
source_url: 'https://nibis.de',
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Create/Update Types
|
||||
export interface CreateKlausurData {
|
||||
title: string
|
||||
subject?: string
|
||||
year?: number
|
||||
semester?: string
|
||||
modus?: 'landes_abitur' | 'vorabitur'
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
|
||||
// Default criteria with weights (NI standard)
|
||||
export const DEFAULT_CRITERIA: Record<string, { name: string; weight: number }> = {
|
||||
rechtschreibung: { name: 'Rechtschreibung', weight: 15 },
|
||||
grammatik: { name: 'Grammatik', weight: 15 },
|
||||
inhalt: { name: 'Inhalt', weight: 40 },
|
||||
struktur: { name: 'Struktur', weight: 15 },
|
||||
stil: { name: 'Stil', weight: 15 },
|
||||
}
|
||||
|
||||
// Grade thresholds (15-point system)
|
||||
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
|
||||
}
|
||||
|
||||
// Helper function to calculate grade from percentage
|
||||
export function calculateGrade(percentage: number): number {
|
||||
for (const [grade, threshold] of Object.entries(GRADE_THRESHOLDS).sort((a, b) => Number(b[0]) - Number(a[0]))) {
|
||||
if (percentage >= threshold) {
|
||||
return Number(grade)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Helper function to get grade label
|
||||
export function getGradeLabel(points: number): string {
|
||||
const labels: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-',
|
||||
12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-',
|
||||
6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-',
|
||||
0: '6'
|
||||
}
|
||||
return labels[points] || String(points)
|
||||
}
|
||||
export * from '@shared/types/klausur'
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||
|
||||
interface QAItem { id: string; question: string; answer: string }
|
||||
|
||||
function getApiBase() { return '' }
|
||||
|
||||
export default function ListenPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||
if (resp.ok) { const d = await resp.json(); setItems(d.qa_items || []) }
|
||||
} catch {} finally { setIsLoading(false) }
|
||||
})()
|
||||
}, [unitId])
|
||||
|
||||
// Generate 4 options (1 correct + 3 distractors)
|
||||
const options = useMemo(() => {
|
||||
if (!items.length || currentIndex >= items.length) return []
|
||||
const correct = items[currentIndex]
|
||||
const others = items.filter((_, i) => i !== currentIndex)
|
||||
const shuffled = [...others].sort(() => Math.random() - 0.5).slice(0, 3)
|
||||
const opts = [...shuffled.map(i => ({ id: i.id, text: i.answer })), { id: correct.id, text: correct.answer }]
|
||||
return opts.sort(() => Math.random() - 0.5)
|
||||
}, [items, currentIndex])
|
||||
|
||||
const handleSelect = useCallback((optionId: string) => {
|
||||
if (revealed) return
|
||||
setSelected(optionId)
|
||||
setRevealed(true)
|
||||
const isCorrect = optionId === items[currentIndex].id
|
||||
|
||||
setStats(prev => ({
|
||||
correct: prev.correct + (isCorrect ? 1 : 0),
|
||||
incorrect: prev.incorrect + (isCorrect ? 0 : 1),
|
||||
}))
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
||||
else { setCurrentIndex(i => i + 1) }
|
||||
setSelected(null)
|
||||
setRevealed(false)
|
||||
}, isCorrect ? 800 : 2000)
|
||||
}, [revealed, items, currentIndex])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-green-50 to-emerald-100'}`}>
|
||||
<div className="w-8 h-8 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
|
||||
const currentItem = items[currentIndex]
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-green-50 to-emerald-100'}`}>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
||||
<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
|
||||
</button>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Hoerverstehen</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{currentIndex + 1}/{items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full h-1 bg-white/10">
|
||||
<div className="h-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||
{isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<StarRating stars={accuracyToStars(stats.correct, stats.correct + stats.incorrect)} size="lg" animated />
|
||||
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Geschafft!</h2>
|
||||
<p className={`text-lg mb-6 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{stats.correct}/{stats.correct + stats.incorrect} richtig</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-medium">Nochmal</button>
|
||||
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||
</div>
|
||||
</div>
|
||||
) : currentItem ? (
|
||||
<div className="w-full max-w-lg space-y-6">
|
||||
{/* Audio prompt */}
|
||||
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Hoere das Wort und waehle die richtige Uebersetzung</p>
|
||||
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="lg" />
|
||||
<p className={`text-sm mt-4 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>Tippe auf den Lautsprecher</p>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{options.map(opt => {
|
||||
const isCorrectAnswer = opt.id === currentItem.id
|
||||
const isSelected = selected === opt.id
|
||||
let style = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-200 text-slate-900'
|
||||
if (revealed && isCorrectAnswer) style = isDark ? 'bg-green-500/30 border-green-400 text-green-200' : 'bg-green-50 border-green-500 text-green-800'
|
||||
if (revealed && isSelected && !isCorrectAnswer) style = isDark ? 'bg-red-500/30 border-red-400 text-red-200' : 'bg-red-50 border-red-500 text-red-800'
|
||||
|
||||
return (
|
||||
<button key={opt.id} onClick={() => handleSelect(opt.id)} disabled={revealed}
|
||||
className={`p-4 rounded-xl border-2 text-center font-medium transition-all ${style}`}>
|
||||
{opt.text}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||
|
||||
interface QAItem { id: string; question: string; answer: string }
|
||||
|
||||
function getApiBase() { return '' }
|
||||
|
||||
export default function MatchPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const [allItems, setAllItems] = useState<QAItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [round, setRound] = useState(0)
|
||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
|
||||
const [matched, setMatched] = useState<Set<string>>(new Set())
|
||||
const [wrongPair, setWrongPair] = useState<string | null>(null)
|
||||
const [errors, setErrors] = useState(0)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||
if (resp.ok) { const d = await resp.json(); setAllItems(d.qa_items || []) }
|
||||
} catch {} finally { setIsLoading(false) }
|
||||
})()
|
||||
}, [unitId])
|
||||
|
||||
// Take 6 items per round
|
||||
const roundItems = useMemo(() => {
|
||||
const start = round * 6
|
||||
return allItems.slice(start, start + 6)
|
||||
}, [allItems, round])
|
||||
|
||||
// Shuffled right column
|
||||
const shuffledRight = useMemo(() => {
|
||||
return [...roundItems].sort(() => Math.random() - 0.5)
|
||||
}, [roundItems])
|
||||
|
||||
const handleLeftTap = useCallback((id: string) => {
|
||||
if (matched.has(id)) return
|
||||
setSelectedLeft(id === selectedLeft ? null : id)
|
||||
setWrongPair(null)
|
||||
}, [selectedLeft, matched])
|
||||
|
||||
const handleRightTap = useCallback((id: string) => {
|
||||
if (!selectedLeft || matched.has(id)) return
|
||||
|
||||
if (selectedLeft === id) {
|
||||
// Correct match
|
||||
setMatched(prev => new Set([...prev, id]))
|
||||
setSelectedLeft(null)
|
||||
|
||||
// Check if round complete
|
||||
if (matched.size + 1 >= roundItems.length) {
|
||||
const nextStart = (round + 1) * 6
|
||||
if (nextStart >= allItems.length) {
|
||||
setTimeout(() => setIsComplete(true), 500)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setRound(r => r + 1)
|
||||
setMatched(new Set())
|
||||
setSelectedLeft(null)
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Wrong match
|
||||
setWrongPair(id)
|
||||
setErrors(e => e + 1)
|
||||
setTimeout(() => {
|
||||
setWrongPair(null)
|
||||
setSelectedLeft(null)
|
||||
}, 600)
|
||||
}
|
||||
}, [selectedLeft, matched, roundItems, round, allItems])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-indigo-50 to-violet-100'}`}>
|
||||
<div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
|
||||
const totalPairs = allItems.length
|
||||
const matchedTotal = round * 6 + matched.size
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-indigo-50 to-violet-100'}`}>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
||||
<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
|
||||
</button>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Zuordnen</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||
{isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<StarRating stars={accuracyToStars(totalPairs, totalPairs + errors)} size="lg" animated />
|
||||
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Alle zugeordnet!</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{errors} Fehler</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setRound(0); setMatched(new Set()); setErrors(0); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">Nochmal</button>
|
||||
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-2xl grid grid-cols-2 gap-6">
|
||||
{/* Left column: English */}
|
||||
<div className="space-y-2">
|
||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>English</p>
|
||||
{roundItems.map(item => (
|
||||
<button
|
||||
key={`l-${item.id}`}
|
||||
onClick={() => handleLeftTap(item.id)}
|
||||
disabled={matched.has(item.id)}
|
||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
matched.has(item.id)
|
||||
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
||||
: selectedLeft === item.id
|
||||
? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
|
||||
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
||||
}`}
|
||||
>
|
||||
{item.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column: German (shuffled) */}
|
||||
<div className="space-y-2">
|
||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Deutsch</p>
|
||||
{shuffledRight.map(item => (
|
||||
<button
|
||||
key={`r-${item.id}`}
|
||||
onClick={() => handleRightTap(item.id)}
|
||||
disabled={matched.has(item.id) || !selectedLeft}
|
||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
matched.has(item.id)
|
||||
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
||||
: wrongPair === item.id
|
||||
? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse')
|
||||
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
||||
}`}
|
||||
>
|
||||
{item.answer}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { MicrophoneInput } from '@/components/learn/MicrophoneInput'
|
||||
import { SyllableBow, simpleSyllableSplit } from '@/components/learn/SyllableBow'
|
||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||
|
||||
interface QAItem {
|
||||
id: string; question: string; answer: string
|
||||
syllables_en?: string[]; syllables_de?: string[]
|
||||
ipa_en?: string; ipa_de?: string
|
||||
}
|
||||
|
||||
function getApiBase() { return '' }
|
||||
|
||||
export default function PronouncePage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [lang, setLang] = useState<'en' | 'de'>('en')
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||
if (resp.ok) { const d = await resp.json(); setItems(d.qa_items || []) }
|
||||
} catch {} finally { setIsLoading(false) }
|
||||
})()
|
||||
}, [unitId])
|
||||
|
||||
const currentItem = items[currentIndex]
|
||||
const currentWord = currentItem ? (lang === 'en' ? currentItem.question : currentItem.answer) : ''
|
||||
const syllables = currentItem
|
||||
? (lang === 'en' ? currentItem.syllables_en : currentItem.syllables_de) || simpleSyllableSplit(currentWord)
|
||||
: []
|
||||
const ipa = currentItem ? (lang === 'en' ? currentItem.ipa_en : currentItem.ipa_de) : ''
|
||||
|
||||
const handleResult = useCallback((transcript: string, correct: boolean) => {
|
||||
setStats(prev => ({
|
||||
correct: prev.correct + (correct ? 1 : 0),
|
||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||
}))
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
||||
else { setCurrentIndex(i => i + 1) }
|
||||
}, correct ? 1000 : 2500)
|
||||
}, [currentIndex, items.length])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-rose-50 to-red-100'}`}>
|
||||
<div className="w-8 h-8 border-4 border-rose-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-rose-50 to-red-100'}`}>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
||||
<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
|
||||
</button>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Nachsprechen</h1>
|
||||
<button onClick={() => setLang(l => l === 'en' ? 'de' : 'en')} className={`text-xs px-3 py-1.5 rounded-lg ${isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{lang === 'en' ? 'EN' : 'DE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full h-1 bg-white/10">
|
||||
<div className="h-full bg-gradient-to-r from-rose-500 to-red-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||
{isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<StarRating stars={accuracyToStars(stats.correct, stats.correct + stats.incorrect)} size="lg" animated />
|
||||
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Super gemacht!</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{stats.correct}/{stats.correct + stats.incorrect} richtig ausgesprochen</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-rose-500 to-red-500 text-white font-medium">Nochmal</button>
|
||||
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||
</div>
|
||||
</div>
|
||||
) : currentItem ? (
|
||||
<div className="w-full max-w-lg space-y-8">
|
||||
{/* Word + Syllables */}
|
||||
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<SyllableBow word={currentWord} syllables={syllables} isDark={isDark} size="lg" />
|
||||
</div>
|
||||
{ipa && <p className={`text-lg ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{ipa}</p>}
|
||||
<div className="flex justify-center gap-3 mt-4">
|
||||
<AudioButton text={currentWord} lang={lang} isDark={isDark} size="lg" />
|
||||
</div>
|
||||
<p className={`text-sm mt-3 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||
Hoere zu, dann sprich nach
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Microphone */}
|
||||
<MicrophoneInput
|
||||
expectedText={currentWord}
|
||||
lang={lang}
|
||||
onResult={handleResult}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
<p className={`text-center text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{currentIndex + 1} / {items.length}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface StarRatingProps {
|
||||
stars: number // 0-3
|
||||
total?: number // total stars earned (shown as badge)
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
animated?: boolean
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const sizeMap = { sm: 'text-lg', md: 'text-2xl', lg: 'text-4xl' }
|
||||
|
||||
export function StarRating({ stars, total, size = 'md', animated = false, showLabel = false }: StarRatingProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`${sizeMap[size]} transition-all ${
|
||||
i <= stars
|
||||
? `text-yellow-400 ${animated ? 'animate-bounce' : ''}`
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
}`}
|
||||
style={animated && i <= stars ? { animationDelay: `${(i - 1) * 150}ms` } : undefined}
|
||||
>
|
||||
{i <= stars ? '\u2B50' : '\u2606'}
|
||||
</span>
|
||||
))}
|
||||
{total != null && showLabel && (
|
||||
<span className="ml-1 text-sm font-bold text-yellow-500 tabular-nums">{total}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Calculate stars from accuracy percentage */
|
||||
export function accuracyToStars(correct: number, total: number): number {
|
||||
if (total === 0) return 0
|
||||
const pct = (correct / total) * 100
|
||||
if (pct >= 100) return 3
|
||||
if (pct >= 70) return 2
|
||||
return 1
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { SyllableBow, simpleSyllableSplit } from './SyllableBow'
|
||||
|
||||
interface FlashCardProps {
|
||||
front: string
|
||||
@@ -11,6 +12,10 @@ interface FlashCardProps {
|
||||
onCorrect: () => void
|
||||
onIncorrect: () => void
|
||||
isDark: boolean
|
||||
syllablesFront?: string[]
|
||||
syllablesBack?: string[]
|
||||
ipaFront?: string
|
||||
ipaBack?: string
|
||||
}
|
||||
|
||||
const boxLabels = ['Neu', 'Gelernt', 'Gefestigt']
|
||||
@@ -25,6 +30,10 @@ export function FlashCard({
|
||||
onCorrect,
|
||||
onIncorrect,
|
||||
isDark,
|
||||
syllablesFront,
|
||||
syllablesBack,
|
||||
ipaFront,
|
||||
ipaBack,
|
||||
}: FlashCardProps) {
|
||||
const [isFlipped, setIsFlipped] = useState(false)
|
||||
|
||||
@@ -69,10 +78,15 @@ export function FlashCard({
|
||||
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
ENGLISCH
|
||||
</span>
|
||||
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{front}
|
||||
</span>
|
||||
<span className={`text-sm mt-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||
{syllablesFront && syllablesFront.length > 0 ? (
|
||||
<SyllableBow word={front} syllables={syllablesFront} isDark={isDark} size="md" />
|
||||
) : (
|
||||
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{front}
|
||||
</span>
|
||||
)}
|
||||
{ipaFront && <span className={`text-sm mt-2 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>{ipaFront}</span>}
|
||||
<span className={`text-sm mt-4 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||
Klick zum Umdrehen
|
||||
</span>
|
||||
</div>
|
||||
@@ -89,9 +103,14 @@ export function FlashCard({
|
||||
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-blue-300/60' : 'text-blue-500'}`}>
|
||||
DEUTSCH
|
||||
</span>
|
||||
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{back}
|
||||
</span>
|
||||
{syllablesBack && syllablesBack.length > 0 ? (
|
||||
<SyllableBow word={back} syllables={syllablesBack} isDark={isDark} size="md" />
|
||||
) : (
|
||||
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{back}
|
||||
</span>
|
||||
)}
|
||||
{ipaBack && <span className={`text-sm mt-2 ${isDark ? 'text-blue-300/40' : 'text-blue-400'}`}>{ipaBack}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,42 +14,66 @@ interface LearningUnit {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface UnitProgress {
|
||||
total_stars?: number
|
||||
exercises?: Record<string, { completed?: number; correct?: number; incorrect?: number }>
|
||||
}
|
||||
|
||||
interface UnitCardProps {
|
||||
unit: LearningUnit
|
||||
progress?: UnitProgress | null
|
||||
wordCount?: number
|
||||
isDark: boolean
|
||||
glassCard: string
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const exerciseTypes = [
|
||||
{ key: 'flashcards', label: 'Karteikarten', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', color: 'from-amber-500 to-orange-500' },
|
||||
{ key: 'flashcards', label: 'Karten', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', color: 'from-amber-500 to-orange-500' },
|
||||
{ key: 'quiz', label: 'Quiz', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', color: 'from-purple-500 to-pink-500' },
|
||||
{ key: 'type', label: 'Eintippen', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', color: 'from-blue-500 to-cyan-500' },
|
||||
{ key: 'story', label: 'Geschichte', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253', color: 'from-amber-500 to-yellow-500' },
|
||||
{ key: 'type', label: 'Tippen', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', color: 'from-blue-500 to-cyan-500' },
|
||||
{ key: 'listen', label: 'Hoeren', icon: 'M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z', color: 'from-green-500 to-emerald-500' },
|
||||
{ key: 'match', label: 'Zuordnen', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', color: 'from-indigo-500 to-violet-500' },
|
||||
{ key: 'pronounce', label: 'Sprechen', icon: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z', color: 'from-rose-500 to-red-500' },
|
||||
]
|
||||
|
||||
export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {
|
||||
export function UnitCard({ unit, progress, wordCount, isDark, glassCard, onDelete }: UnitCardProps) {
|
||||
const createdDate = new Date(unit.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})
|
||||
|
||||
// Calculate progress percentage from exercises
|
||||
const totalCorrect = progress?.exercises
|
||||
? Object.values(progress.exercises).reduce((s, e) => s + (e?.correct || 0), 0)
|
||||
: 0
|
||||
const totalAnswered = progress?.exercises
|
||||
? Object.values(progress.exercises).reduce((s, e) => s + (e?.completed || 0), 0)
|
||||
: 0
|
||||
const progressPct = totalAnswered > 0 ? Math.round((totalCorrect / totalAnswered) * 100) : 0
|
||||
const stars = progress?.total_stars || 0
|
||||
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6 transition-all hover:shadow-lg`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{unit.label}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{unit.meta}
|
||||
<div className={`${glassCard} rounded-2xl p-5 transition-all hover:shadow-lg`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`text-lg font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{unit.label}
|
||||
</h3>
|
||||
{stars > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-yellow-400 text-sm font-bold">
|
||||
<span>⭐</span>{stars}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm mt-0.5 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{wordCount ? `${wordCount} Woerter` : unit.meta} · {createdDate}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(unit.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10 text-white/40 hover:text-red-400' : 'hover:bg-slate-100 text-slate-400 hover:text-red-500'}`}
|
||||
title="Loeschen"
|
||||
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10 text-white/30 hover:text-red-400' : 'hover:bg-slate-100 text-slate-300 hover:text-red-500'}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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" />
|
||||
@@ -57,35 +81,36 @@ export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Exercise Type Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Progress Bar */}
|
||||
{totalAnswered > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-green-500 transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{progressPct}% · {totalCorrect}/{totalAnswered} richtig
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exercise Buttons */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{exerciseTypes.map((ex) => (
|
||||
<Link
|
||||
key={ex.key}
|
||||
href={`/learn/${unit.id}/${ex.key}`}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-white bg-gradient-to-r ${ex.color} hover:shadow-lg hover:scale-[1.02] transition-all`}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-white bg-gradient-to-r ${ex.color} hover:shadow-md hover:scale-[1.02] transition-all`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ex.icon} />
|
||||
</svg>
|
||||
{ex.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className={`flex items-center gap-3 mt-4 pt-3 border-t ${isDark ? 'border-white/10' : 'border-black/5'}`}>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Erstellt: {createdDate}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
unit.status === 'qa_generated' || unit.status === 'mc_generated' || unit.status === 'cloze_generated'
|
||||
? (isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700')
|
||||
: (isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700')
|
||||
}`}>
|
||||
{unit.status === 'raw' ? 'Neu' : 'Module generiert'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,329 +1 @@
|
||||
/**
|
||||
* TypeScript Types for Companion Module
|
||||
* Migration from Flask companion.py/companion_js.py
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Phase System
|
||||
// ============================================================================
|
||||
|
||||
export type PhaseId = 'einstieg' | 'erarbeitung' | 'sicherung' | 'transfer' | 'reflexion'
|
||||
|
||||
export interface Phase {
|
||||
id: PhaseId
|
||||
shortName: string // E, A, S, T, R
|
||||
displayName: string
|
||||
duration: number // minutes
|
||||
status: 'planned' | 'active' | 'completed'
|
||||
actualTime?: number // seconds (actual time spent)
|
||||
color: string // hex color
|
||||
}
|
||||
|
||||
export interface PhaseContext {
|
||||
currentPhase: PhaseId
|
||||
phaseDisplayName: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard / Companion Mode
|
||||
// ============================================================================
|
||||
|
||||
export interface CompanionStats {
|
||||
classesCount: number
|
||||
studentsCount: number
|
||||
learningUnitsCreated: number
|
||||
gradesEntered: number
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
percentage: number
|
||||
completed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type SuggestionPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
priority: SuggestionPriority
|
||||
icon: string // lucide icon name
|
||||
actionTarget: string // navigation path
|
||||
estimatedTime: number // minutes
|
||||
}
|
||||
|
||||
export type EventType = 'exam' | 'parent_meeting' | 'deadline' | 'other'
|
||||
|
||||
export interface UpcomingEvent {
|
||||
id: string
|
||||
title: string
|
||||
date: string // ISO date string
|
||||
type: EventType
|
||||
inDays: number
|
||||
}
|
||||
|
||||
export interface CompanionData {
|
||||
context: PhaseContext
|
||||
stats: CompanionStats
|
||||
phases: Phase[]
|
||||
progress: Progress
|
||||
suggestions: Suggestion[]
|
||||
upcomingEvents: UpcomingEvent[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Mode
|
||||
// ============================================================================
|
||||
|
||||
export type LessonStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'overtime'
|
||||
|
||||
export interface LessonPhase {
|
||||
phase: PhaseId
|
||||
duration: number // planned duration in minutes
|
||||
status: 'planned' | 'active' | 'completed' | 'skipped'
|
||||
actualTime: number // actual time spent in seconds
|
||||
startedAt?: string // ISO timestamp
|
||||
completedAt?: string // ISO timestamp
|
||||
}
|
||||
|
||||
export interface Homework {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
dueDate: string // ISO date
|
||||
attachments?: string[]
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export interface Material {
|
||||
id: string
|
||||
title: string
|
||||
type: 'document' | 'video' | 'presentation' | 'link' | 'other'
|
||||
url?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface LessonReflection {
|
||||
rating: number // 1-5 stars
|
||||
notes: string
|
||||
nextSteps: string
|
||||
savedAt?: string
|
||||
}
|
||||
|
||||
export interface LessonSession {
|
||||
sessionId: string
|
||||
classId: string
|
||||
className: string
|
||||
subject: string
|
||||
topic?: string
|
||||
startTime: string // ISO timestamp
|
||||
endTime?: string // ISO timestamp
|
||||
phases: LessonPhase[]
|
||||
totalPlannedDuration: number // minutes
|
||||
currentPhaseIndex: number
|
||||
elapsedTime: number // seconds
|
||||
isPaused: boolean
|
||||
pausedAt?: string
|
||||
pauseDuration: number // total pause time in seconds
|
||||
overtimeMinutes: number
|
||||
status: LessonStatus
|
||||
homeworkList: Homework[]
|
||||
materials: Material[]
|
||||
reflection?: LessonReflection
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lesson Templates
|
||||
// ============================================================================
|
||||
|
||||
export interface PhaseDurations {
|
||||
einstieg: number
|
||||
erarbeitung: number
|
||||
sicherung: number
|
||||
transfer: number
|
||||
reflexion: number
|
||||
}
|
||||
|
||||
export interface LessonTemplate {
|
||||
templateId: string
|
||||
name: string
|
||||
description?: string
|
||||
subject?: string
|
||||
durations: PhaseDurations
|
||||
isSystemTemplate: boolean
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings
|
||||
// ============================================================================
|
||||
|
||||
export interface TeacherSettings {
|
||||
defaultPhaseDurations: PhaseDurations
|
||||
preferredLessonLength: number // minutes (default 45)
|
||||
autoAdvancePhases: boolean
|
||||
soundNotifications: boolean
|
||||
showKeyboardShortcuts: boolean
|
||||
highContrastMode: boolean
|
||||
onboardingCompleted: boolean
|
||||
selectedTemplateId?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timer State
|
||||
// ============================================================================
|
||||
|
||||
export type TimerColorStatus = 'plenty' | 'warning' | 'critical' | 'overtime'
|
||||
|
||||
export interface TimerState {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
elapsedSeconds: number
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
progress: number // 0-1
|
||||
colorStatus: TimerColorStatus
|
||||
currentPhase: LessonPhase | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Forms
|
||||
// ============================================================================
|
||||
|
||||
export interface LessonStartFormData {
|
||||
classId: string
|
||||
subject: string
|
||||
topic?: string
|
||||
templateId?: string
|
||||
customDurations?: PhaseDurations
|
||||
}
|
||||
|
||||
export interface Class {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
studentCount: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feedback
|
||||
// ============================================================================
|
||||
|
||||
export type FeedbackType = 'bug' | 'feature' | 'feedback'
|
||||
|
||||
export interface FeedbackSubmission {
|
||||
type: FeedbackType
|
||||
title: string
|
||||
description: string
|
||||
screenshot?: string // base64
|
||||
sessionId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Onboarding
|
||||
// ============================================================================
|
||||
|
||||
export interface OnboardingStep {
|
||||
step: number
|
||||
title: string
|
||||
description: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
steps: OnboardingStep[]
|
||||
selectedState?: string // Bundesland
|
||||
selectedSchoolType?: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Messages
|
||||
// ============================================================================
|
||||
|
||||
export type WSMessageType =
|
||||
| 'phase_update'
|
||||
| 'timer_tick'
|
||||
| 'overtime_warning'
|
||||
| 'pause_toggle'
|
||||
| 'session_end'
|
||||
| 'sync_request'
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType
|
||||
payload: {
|
||||
sessionId: string
|
||||
phase?: number
|
||||
elapsed?: number
|
||||
isPaused?: boolean
|
||||
overtimeMinutes?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Responses
|
||||
// ============================================================================
|
||||
|
||||
export interface APIResponse<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DashboardResponse extends APIResponse<CompanionData> {}
|
||||
|
||||
export interface LessonResponse extends APIResponse<LessonSession> {}
|
||||
|
||||
export interface TemplatesResponse extends APIResponse<{ templates: LessonTemplate[] }> {}
|
||||
|
||||
export interface SettingsResponse extends APIResponse<TeacherSettings> {}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export type CompanionMode = 'companion' | 'lesson' | 'classic'
|
||||
|
||||
export interface ModeToggleProps {
|
||||
currentMode: CompanionMode
|
||||
onModeChange: (mode: CompanionMode) => void
|
||||
}
|
||||
|
||||
export interface PhaseTimelineProps {
|
||||
phases: Phase[]
|
||||
currentPhaseIndex: number
|
||||
onPhaseClick?: (index: number) => void
|
||||
}
|
||||
|
||||
export interface VisualPieTimerProps {
|
||||
progress: number // 0-1
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
currentPhaseName: string
|
||||
phaseColor: string
|
||||
}
|
||||
|
||||
export interface QuickActionsBarProps {
|
||||
onExtend: (minutes: number) => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onSkip: () => void
|
||||
isPaused: boolean
|
||||
isLastPhase: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
export * from '@shared/types/companion'
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"../shared/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
|
||||
+4
-3
@@ -4,13 +4,14 @@ FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY website/package.json website/package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy source code + shared types
|
||||
COPY website/ .
|
||||
COPY shared/ /shared/
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG NEXT_PUBLIC_BILLING_API_URL
|
||||
|
||||
@@ -1,195 +1 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR läuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse läuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
export * from '@shared/types/klausur'
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available OCR Models
|
||||
*
|
||||
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||
*/
|
||||
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||
|
||||
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||
'llama3.2-vision:11b': {
|
||||
label: 'Vision LLM',
|
||||
description: 'Beste Qualitaet bei Handschrift',
|
||||
speed: 'langsam',
|
||||
},
|
||||
trocr: {
|
||||
label: 'Microsoft TrOCR',
|
||||
description: 'Schnell bei gedrucktem Text',
|
||||
speed: 'schnell',
|
||||
},
|
||||
paddleocr: {
|
||||
label: 'PaddleOCR + LLM',
|
||||
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||
speed: 'sehr schnell',
|
||||
},
|
||||
donut: {
|
||||
label: 'Donut',
|
||||
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||
speed: 'mittel',
|
||||
},
|
||||
}
|
||||
|
||||
export interface OCRSession {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRItem {
|
||||
id: string
|
||||
session_id: string
|
||||
session_name: string
|
||||
image_path: string
|
||||
image_url?: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
ground_truth?: string
|
||||
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRStats {
|
||||
total_sessions?: number
|
||||
session_id?: string
|
||||
name?: string
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items?: number
|
||||
pending_items: number
|
||||
exportable_items?: number
|
||||
accuracy_rate: number
|
||||
avg_label_time_seconds?: number
|
||||
progress_percent?: number
|
||||
}
|
||||
|
||||
export interface TrainingSample {
|
||||
id: string
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
training_batch: string
|
||||
exported_at?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
item_id: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface CorrectRequest {
|
||||
item_id: string
|
||||
ground_truth: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
session_id?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
filename: string
|
||||
image_path: string
|
||||
image_hash: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
status: string
|
||||
}
|
||||
export * from '@shared/types/ocr-labeling'
|
||||
|
||||
@@ -1,195 +1 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR läuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse läuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
export * from '@shared/types/klausur'
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
/**
|
||||
* Types and constants for the Klausur-Korrektur list page.
|
||||
* Shared between admin and lehrer routes.
|
||||
*
|
||||
* Domain types are re-exported from the shared module.
|
||||
* Only the API_BASE constant remains local (env-dependent).
|
||||
*/
|
||||
|
||||
export type {
|
||||
TabId,
|
||||
CreateKlausurForm,
|
||||
VorabiturEHForm,
|
||||
EHTemplate,
|
||||
DirektuploadForm,
|
||||
} from '@shared/types/klausur'
|
||||
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,81 +1,23 @@
|
||||
/**
|
||||
* Types and constants for the Korrektur-Workspace.
|
||||
* Shared between admin and lehrer routes.
|
||||
*
|
||||
* Domain types are re-exported from the shared module.
|
||||
* Only the API_BASE constant remains local (env-dependent).
|
||||
*/
|
||||
|
||||
import type { CriteriaScores } from '../../app/admin/klausur-korrektur/types'
|
||||
export type {
|
||||
ExaminerInfo,
|
||||
ExaminerResult,
|
||||
ExaminerWorkflow,
|
||||
ActiveTab,
|
||||
CriteriaScores,
|
||||
} from '@shared/types/klausur'
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
|
||||
|
||||
// Workflow status labels
|
||||
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' },
|
||||
}
|
||||
export {
|
||||
WORKFLOW_STATUS_LABELS,
|
||||
ROLE_LABELS,
|
||||
GRADE_LABELS,
|
||||
} from '@shared/types/klausur'
|
||||
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"../shared/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
|
||||
Reference in New Issue
Block a user