Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
7.9 KiB
TypeScript
259 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo } from 'react'
|
|
import type { CriteriaScores, Annotation } from '@/app/korrektur/types'
|
|
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, calculateGrade, getGradeLabel } from '@/app/korrektur/types'
|
|
|
|
interface CriteriaPanelProps {
|
|
scores: CriteriaScores
|
|
annotations: Annotation[]
|
|
onScoreChange: (criterion: string, value: number) => void
|
|
onLoadEHSuggestions?: (criterion: string) => void
|
|
isLoading?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export function CriteriaPanel({
|
|
scores,
|
|
annotations,
|
|
onScoreChange,
|
|
onLoadEHSuggestions,
|
|
isLoading = false,
|
|
className = '',
|
|
}: CriteriaPanelProps) {
|
|
// Count annotations per criterion
|
|
const annotationCounts = useMemo(() => {
|
|
const counts: Record<string, number> = {}
|
|
for (const annotation of annotations) {
|
|
const type = annotation.linked_criterion || annotation.type
|
|
counts[type] = (counts[type] || 0) + 1
|
|
}
|
|
return counts
|
|
}, [annotations])
|
|
|
|
// Calculate total grade
|
|
const { totalWeightedScore, totalWeight, gradePoints, gradeLabel } = useMemo(() => {
|
|
let weightedScore = 0
|
|
let weight = 0
|
|
|
|
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
|
|
const score = scores[criterion]
|
|
if (score !== undefined) {
|
|
weightedScore += score * config.weight
|
|
weight += config.weight
|
|
}
|
|
}
|
|
|
|
const percentage = weight > 0 ? weightedScore / weight : 0
|
|
const grade = calculateGrade(percentage)
|
|
const label = getGradeLabel(grade)
|
|
|
|
return {
|
|
totalWeightedScore: weightedScore,
|
|
totalWeight: weight,
|
|
gradePoints: grade,
|
|
gradeLabel: label,
|
|
}
|
|
}, [scores])
|
|
|
|
return (
|
|
<div className={`space-y-4 ${className}`}>
|
|
{/* Criteria List */}
|
|
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
|
const score = scores[criterion] || 0
|
|
const annotationCount = annotationCounts[criterion] || 0
|
|
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
|
|
|
return (
|
|
<CriterionCard
|
|
key={criterion}
|
|
id={criterion}
|
|
name={config.name}
|
|
weight={config.weight}
|
|
score={score}
|
|
annotationCount={annotationCount}
|
|
color={color}
|
|
onScoreChange={(value) => onScoreChange(criterion, value)}
|
|
onLoadSuggestions={
|
|
onLoadEHSuggestions
|
|
? () => onLoadEHSuggestions(criterion)
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Total Score */}
|
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-purple-500/20 to-pink-500/20 border border-purple-500/30">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-white/60 text-sm">Gesamtnote</span>
|
|
<span className="text-2xl font-bold text-white">
|
|
{gradePoints} Punkte
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-white/40 text-xs">
|
|
{Math.round(totalWeightedScore / totalWeight)}% gewichtet
|
|
</span>
|
|
<span className="text-lg font-semibold text-purple-300">
|
|
({gradeLabel})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CRITERION CARD
|
|
// =============================================================================
|
|
|
|
interface CriterionCardProps {
|
|
id: string
|
|
name: string
|
|
weight: number
|
|
score: number
|
|
annotationCount: number
|
|
color: string
|
|
onScoreChange: (value: number) => void
|
|
onLoadSuggestions?: () => void
|
|
}
|
|
|
|
function CriterionCard({
|
|
id,
|
|
name,
|
|
weight,
|
|
score,
|
|
annotationCount,
|
|
color,
|
|
onScoreChange,
|
|
onLoadSuggestions,
|
|
}: CriterionCardProps) {
|
|
const gradePoints = calculateGrade(score)
|
|
const gradeLabel = getGradeLabel(gradePoints)
|
|
|
|
return (
|
|
<div className="p-4 rounded-2xl bg-white/5 border border-white/10">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span className="text-white font-medium">{name}</span>
|
|
<span className="text-white/40 text-xs">({weight}%)</span>
|
|
</div>
|
|
<span className="text-white/60 text-sm">
|
|
{gradePoints} P ({gradeLabel})
|
|
</span>
|
|
</div>
|
|
|
|
{/* Slider */}
|
|
<div className="relative mb-3">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={score}
|
|
onChange={(e) => onScoreChange(Number(e.target.value))}
|
|
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer"
|
|
style={{
|
|
background: `linear-gradient(to right, ${color} ${score}%, rgba(255,255,255,0.1) ${score}%)`,
|
|
}}
|
|
/>
|
|
<div className="flex justify-between mt-1 text-xs text-white/30">
|
|
<span>0</span>
|
|
<span>25</span>
|
|
<span>50</span>
|
|
<span>75</span>
|
|
<span>100</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{annotationCount > 0 && (
|
|
<span
|
|
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style={{ backgroundColor: `${color}20`, color }}
|
|
>
|
|
{annotationCount} Anmerkung{annotationCount !== 1 ? 'en' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{onLoadSuggestions && (id === 'inhalt' || id === 'struktur') && (
|
|
<button
|
|
onClick={onLoadSuggestions}
|
|
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
EH-Vorschlaege
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPACT CRITERIA SUMMARY
|
|
// =============================================================================
|
|
|
|
interface CriteriaSummaryProps {
|
|
scores: CriteriaScores
|
|
className?: string
|
|
}
|
|
|
|
export function CriteriaSummary({ scores, className = '' }: CriteriaSummaryProps) {
|
|
const { gradePoints, gradeLabel } = useMemo(() => {
|
|
let weightedScore = 0
|
|
let weight = 0
|
|
|
|
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
|
|
const score = scores[criterion]
|
|
if (score !== undefined) {
|
|
weightedScore += score * config.weight
|
|
weight += config.weight
|
|
}
|
|
}
|
|
|
|
const percentage = weight > 0 ? weightedScore / weight : 0
|
|
const grade = calculateGrade(percentage)
|
|
const label = getGradeLabel(grade)
|
|
|
|
return { gradePoints: grade, gradeLabel: label }
|
|
}, [scores])
|
|
|
|
return (
|
|
<div className={`flex items-center gap-3 ${className}`}>
|
|
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
|
const score = scores[criterion] || 0
|
|
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
|
|
|
return (
|
|
<div
|
|
key={criterion}
|
|
className="flex items-center gap-1"
|
|
title={`${config.name}: ${score}%`}
|
|
>
|
|
<div
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span className="text-white/60 text-xs">{score}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
<div className="w-px h-4 bg-white/20" />
|
|
<span className="text-white font-medium text-sm">
|
|
{gradePoints} ({gradeLabel})
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|