'use client'; import React, { useEffect, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart, } from 'recharts'; import { Calculator, Plus, TrendingUp, BarChart3, Target, Download, BookOpen, Star, ThumbsUp, AlertTriangle, Circle, Activity, Award, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Textarea } from '@/components/ui/textarea'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import StudentAvatar from '@/components/student-avatar'; import { fetchClasses, fetchClassStudents, fetchSubjects, fetchGradingSchemes, createGradingScheme, fetchSchoolYears, downloadCsvExport, addNotification, type ClassGroup, type Student, type Subject, type GradingScheme, type SchoolYear, } from '@/lib/api'; import { toast } from 'sonner'; const gradeColor = (value: number) => { if (value <= 1.5) return { bg: 'bg-gradient-to-br from-emerald-400 to-emerald-500', text: 'text-white', shadow: 'shadow-emerald-300/30', border: 'border-l-emerald-500', badge: 'grade-badge-1', label: t('grading.sehr_gut') }; if (value <= 2.5) return { bg: 'bg-gradient-to-br from-teal-400 to-teal-500', text: 'text-white', shadow: 'shadow-teal-300/30', border: 'border-l-teal-500', badge: 'grade-badge-2', label: t('grading.gut') }; if (value <= 3.5) return { bg: 'bg-gradient-to-br from-amber-400 to-amber-500', text: 'text-white', shadow: 'shadow-amber-300/30', border: 'border-l-amber-500', badge: 'grade-badge-3', label: t('grading.befriedigend') }; if (value <= 4.5) return { bg: 'bg-gradient-to-br from-orange-400 to-orange-500', text: 'text-white', shadow: 'shadow-orange-300/30', border: 'border-l-orange-500', badge: 'grade-badge-4', label: t('grading.ausreichend') }; if (value <= 5.5) return { bg: 'bg-gradient-to-br from-rose-400 to-rose-500', text: 'text-white', shadow: 'shadow-rose-300/30', border: 'border-l-rose-500', badge: 'grade-badge-5', label: t('grading.mangelhaft') }; return { bg: 'bg-gradient-to-br from-red-500 to-red-600', text: 'text-white', shadow: 'shadow-red-300/30', border: 'border-l-red-500', badge: 'grade-badge-6', label: t('grading.ungenuegend') }; }; const GradeIcon = ({ value, className = 'w-3 h-3' }: { value: number; className?: string }) => { if (value <= 1.5) return ; if (value <= 2.5) return ; if (value <= 3.5) return ; return ; }; const weightSourceColor = (source: string) => { if (source === 'LEARNING_PROGRESS') return { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', bar: 'from-emerald-400 to-emerald-500', dot: 'bg-emerald-500' }; if (source === 'ASSESSMENT') return { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', bar: 'from-amber-400 to-amber-500', dot: 'bg-amber-500' }; return { bg: 'bg-gray-100 dark:bg-gray-900/30', text: 'text-gray-700 dark:text-gray-300', bar: 'from-gray-400 to-gray-500', dot: 'bg-gray-500' }; }; export default function GradingView() { const currentUser = useAppStore((s) => s.currentUser); const currentClassId = useAppStore((s) => s.currentClassId); const [classes, setClasses] = useState([]); const [selectedClass, setSelectedClass] = useState(null); const [subjects, setSubjects] = useState([]); const [selectedSubjectId, setSelectedSubjectId] = useState(''); const [students, setStudents] = useState([]); const [schemes, setSchemes] = useState([]); const [computedGrades, setComputedGrades] = useState }> | null>(null); const [schoolYears, setSchoolYears] = useState([]); const [loading, setLoading] = useState(true); const [computing, setComputing] = useState(false); // Create scheme dialog const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(''); const [newType, setNewType] = useState('NUMERIC_GRADE'); const [newScale, setNewScale] = useState(JSON.stringify({ min: 1, max: 6, labels: { '1': 'sehr gut', '6': 'ungenügend' } })); const [newWeightRules, setNewWeightRules] = useState>([ { sourceType: 'LEARNING_PROGRESS', targetRef: '', weightPercent: 50 }, { sourceType: 'ASSESSMENT', targetRef: '', weightPercent: 50 }, ]); const [creating, setCreating] = useState(false); // School-wide grading overview (loaded only when no class is selected, for the empty state) const [overviewSchemes, setOverviewSchemes] = useState([]); const [overviewLoading, setOverviewLoading] = useState(false); useEffect(() => { async function load() { try { const [cls, subs] = await Promise.all([ fetchClasses(currentUser?.schoolId ?? undefined), fetchSubjects(currentUser?.schoolId ?? undefined), ]); setClasses(cls); setSubjects(subs); if (currentClassId) { const found = cls.find((c) => c.id === currentClassId); if (found) handleSelectClass(found); } } catch { // ignore } finally { setLoading(false); } } load(); }, [currentUser?.schoolId]); // Load school-wide grading overview only when no class is selected useEffect(() => { if (selectedClass || overviewSchemes.length > 0) return; let cancelled = false; setOverviewLoading(true); fetchGradingSchemes({ schoolId: currentUser?.schoolId ?? undefined }) .then((data) => { if (cancelled) return; if (Array.isArray(data)) setOverviewSchemes(data); else setOverviewSchemes(data.schemes); }) .catch(() => { // ignore }) .finally(() => { if (!cancelled) setOverviewLoading(false); }); return () => { cancelled = true; }; }, [selectedClass, overviewSchemes.length, currentUser?.schoolId]); // Overview computed stats const overviewStats = useMemo(() => { const total = overviewSchemes.length; const subjectsCovered = new Set(overviewSchemes.map((s) => s.subjectId).filter(Boolean)).size; const byType = overviewSchemes.reduce>((acc, s) => { acc[s.type] = (acc[s.type] ?? 0) + 1; return acc; }, {}); const entries = Object.entries(byType).sort((a, b) => b[1] - a[1]); const maxCount = entries.length > 0 ? entries[0][1] : 0; return { total, subjectsCovered, entries, maxCount }; }, [overviewSchemes]); const schemeTypeColor = (type: string) => { if (type === 'NUMERIC_GRADE') return 'bg-emerald-500'; if (type === 'COMPETENCY_BASED') return 'bg-teal-500'; if (type === 'BEHAVIORAL') return 'bg-amber-500'; return 'bg-violet-500'; }; const schemeTypeLabel = (type: string) => { if (type === 'NUMERIC_GRADE') return '1–6'; if (type === 'COMPETENCY_BASED') return 'Komp.'; if (type === 'BEHAVIORAL') return 'Verh.'; return type.slice(0, 5); }; async function handleSelectClass(cls: ClassGroup) { setSelectedClass(cls); useAppStore.getState().setCurrentClass(cls.id); try { const [s, yrs] = await Promise.all([ fetchClassStudents(cls.id), fetchSchoolYears(cls.schoolId), ]); setStudents(s); setSchoolYears(yrs); } catch { // ignore } } async function loadSchemes(classGroupId: string, subjectId: string) { try { const data = await fetchGradingSchemes({ classGroupId, subjectId }); if (Array.isArray(data)) { setSchemes(data); setComputedGrades(null); } else { setSchemes(data.schemes); setComputedGrades(data.computedGrades); } } catch { setSchemes([]); setComputedGrades(null); } } const handleSelectSubject = async (subjectId: string) => { setSelectedSubjectId(subjectId); if (selectedClass) { await loadSchemes(selectedClass.id, subjectId); } }; const handleComputeGrades = async () => { if (!selectedClass || !selectedSubjectId) return; const schoolYearId = selectedClass.schoolYearId; setComputing(true); try { const data = await fetchGradingSchemes({ classGroupId: selectedClass.id, subjectId: selectedSubjectId, computeGrades: true, schoolYearId, }); if (!Array.isArray(data)) { setSchemes(data.schemes); setComputedGrades(data.computedGrades); } toast.success(t('grading.grades_computed')); addNotification({ type: 'grade', message: `${t('notification.grade_computed')}: ${selectedClass?.name ?? ''}`, timestamp: new Date().toISOString(), }); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } finally { setComputing(false); } }; const handleCreateScheme = async () => { if (!selectedClass || !selectedSubjectId) return; setCreating(true); try { await createGradingScheme({ classGroupId: selectedClass.id, subjectId: selectedSubjectId, name: newName, type: newType, scaleDefinition: newScale, weightRules: newWeightRules, }); toast.success(t('toast.created')); setCreateOpen(false); setNewName(''); loadSchemes(selectedClass.id, selectedSubjectId); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } finally { setCreating(false); } }; const addWeightRule = () => { setNewWeightRules([...newWeightRules, { sourceType: 'LEARNING_PROGRESS', targetRef: '', weightPercent: 0 }]); }; const updateWeightRule = (index: number, field: string, value: string | number) => { const rules = [...newWeightRules]; rules[index] = { ...rules[index], [field]: value }; setNewWeightRules(rules); }; const removeWeightRule = (index: number) => { setNewWeightRules(newWeightRules.filter((_, i) => i !== index)); }; if (loading) { return
; } return ( {/* Section header */}

{t('grading.title')}

{t('grading.empty_description')}

{/* Selection header */}
{selectedClass && (
)} {selectedClass && selectedSubjectId && (
)}
{!selectedClass || !selectedSubjectId ? (

{t('polish.empty_title_grading')}

{t('grading.empty_description')}

{/* Grading Overview card */}
{t('polish.grading_overview_title')} · {t('polish.grading_overview_subtitle')}
{overviewLoading ? (
) : ( <> {/* Three mini-stats */}

{t('polish.grading_total_schemes')}

{overviewStats.total}

{t('polish.grading_subjects_covered')}

{overviewStats.subjectsCovered}

{t('polish.grading_distribution')}

{overviewStats.entries.length}

{/* Distribution mini-bar */} {overviewStats.entries.length > 0 && (

{t('polish.grading_distribution')}

{overviewStats.entries.map(([type, count]) => (
{schemeTypeLabel(type)}
0 ? (count / overviewStats.maxCount) * 100 : 0}%` }} />
{count}
))}
)} )} ) : ( <> {/* Schemes */}
{t('grading.schemes')} {schemes.length}
{schemes.length === 0 ? (

{t('polish.empty_title_no_data')}

{t('grading.no_schemes')}

) : (
{schemes.map((scheme) => (

{scheme.name}

{t(`grading_type.${scheme.type.toLowerCase()}`)} · {scheme.classGroup?.name ?? '—'} · {scheme.subject?.name ?? '—'}

{scheme.gradingWeightRules.length > 0 && (

{t('grading.weight_rules')}

{/* Visual weight breakdown bar */}
{scheme.gradingWeightRules.map((rule, i) => { const colors = weightSourceColor(rule.sourceType); return (
); })}
{scheme.gradingWeightRules.map((rule) => { const colors = weightSourceColor(rule.sourceType); return (
{t(`weight_source.${rule.sourceType.toLowerCase()}`)}: {rule.weightPercent}% {rule.targetRef && ( ({rule.targetRef}) )}
); })}
)} ))}
)} {/* Computed grades */}
{t('grading.computed_grades')}
{!computedGrades ? (

{t('grading.no_grades')}

{t('grading.click_compute')}

) : ( <> {/* Grade statistics card */} {computedGrades.length > 0 && (
{(() => { const values = computedGrades.map((g) => g.computedValue).sort((a, b) => a - b); const avg = values.reduce((s, v) => s + v, 0) / values.length; const median = values.length % 2 === 0 ? (values[values.length / 2 - 1] + values[values.length / 2]) / 2 : values[Math.floor(values.length / 2)]; const min = values[0]; const max = values[values.length - 1]; const stats = [ { label: t('polish.class_average'), value: avg.toFixed(2), color: 'text-emerald-700 dark:text-emerald-300', bg: 'from-emerald-50 to-emerald-100/50 dark:from-emerald-900/20 dark:to-emerald-800/10', border: 'border-emerald-200/40 dark:border-emerald-900/30' }, { label: t('polish.median'), value: median.toFixed(2), color: 'text-teal-700 dark:text-teal-300', bg: 'from-teal-50 to-teal-100/50 dark:from-teal-900/20 dark:to-teal-800/10', border: 'border-teal-200/40 dark:border-teal-900/30' }, { label: t('polish.minimum'), value: min.toFixed(2), color: 'text-red-700 dark:text-red-300', bg: 'from-red-50 to-red-100/50 dark:from-red-900/20 dark:to-red-800/10', border: 'border-red-200/40 dark:border-red-900/30' }, { label: t('polish.maximum'), value: max.toFixed(2), color: 'text-emerald-700 dark:text-emerald-300', bg: 'from-emerald-50 to-teal-100/50 dark:from-emerald-900/20 dark:to-teal-800/10', border: 'border-emerald-200/40 dark:border-emerald-900/30' }, { label: 'n', value: String(values.length), color: 'text-gray-700 dark:text-gray-300', bg: 'from-gray-50 to-gray-100/50 dark:from-gray-800/40 dark:to-gray-800/20', border: 'border-gray-200/40 dark:border-gray-700/30' }, ]; return stats.map((s) => (

{s.label}

{s.value}

)); })()}
)} {/* Grade interpretation legend + grade trend */} {computedGrades.length > 0 && (
{/* Legend */}

{t('polish.grade_legend')}

1–2 · {t('polish.grade_good')}
3–4 · {t('polish.grade_satisfactory')}
5–6 · {t('polish.grade_needs_work')}
{/* Trend (deterministic pseudo) */}

{t('polish.grade_trend')}

{(() => { const avg = computedGrades.reduce((s, g) => s + g.computedValue, 0) / computedGrades.length; // Deterministic pseudo-trend: previous was slightly higher/lower let h = 0; for (let i = 0; i < (selectedClass?.id ?? 'x').length; i++) h = (h * 31 + (selectedClass?.id ?? 'x').charCodeAt(i)) | 0; h = Math.abs(h); const prevAvg = avg + (((h % 7) - 3) / 10); const diff = avg - prevAvg; const improved = diff < 0; // lower grade value = better in German system return (
{t('polish.class_average')}: Ø {avg.toFixed(2)} → {prevAvg.toFixed(2)} {improved ? '↓' : '↑'} {Math.abs(diff).toFixed(2)}
); })()}
)} {/* Grade distribution mini chart */} {computedGrades.length > 0 && (

{t('grading.grade_distribution')}

{[1, 2, 3, 4, 5, 6].map((gradeValue) => { const count = computedGrades.filter((g) => Math.round(g.computedValue) === gradeValue).length; const maxCount = Math.max(...[1, 2, 3, 4, 5, 6].map((v) => computedGrades.filter((g) => Math.round(g.computedValue) === v).length), 1); const height = (count / maxCount) * 100; const pct = computedGrades.length > 0 ? (count / computedGrades.length) * 100 : 0; const gColors = gradeColor(gradeValue); return ( {pct.toFixed(0)}%
0 ? '6px' : '2px' }} />
{gradeValue}
); })}
)}
{t('grading.student')}
{t('grading.computed_value')}
{t('grading.breakdown')}
{computedGrades.map((grade, idx) => { const student = students.find((s) => s.id === grade.studentId); const colors = gradeColor(grade.computedValue); return (
{student ? `${student.firstName} ${student.lastName}` : grade.studentId}
{grade.computedValue.toFixed(1)} {colors.label}
{grade.breakdown.map((b, i) => { const bColors = weightSourceColor(b.source); return (
{t(`weight_source.${b.source.toLowerCase()}`)} ({b.weight}%)
{b.value.toFixed(2)}
); })}
); })}
)}
{/* Grade Trend Over Time */} {computedGrades && computedGrades.length > 0 && (
{t('grading.trend_title')} - {t('grading.trend_desc')}
{(() => { const classAvg = computedGrades.reduce((s, g) => s + g.computedValue, 0) / computedGrades.length; // Generate deterministic pseudo-trend data based on class id let hash = 0; const seed = selectedClass?.id ?? 'x'; for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0; hash = Math.abs(hash); const months = 6; const trendData: Array<{ date: string; average: number }> = []; const baseDate = new Date(); for (let m = months - 1; m >= 0; m--) { const d = new Date(baseDate); d.setMonth(d.getMonth() - m); const dateStr = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }); const noise = ((hash >> (m * 5)) & 15) / 100 - 0.08; const trendVal = Math.max(1, Math.min(6, classAvg + noise - (m * 0.04))); trendData.push({ date: dateStr, average: parseFloat(trendVal.toFixed(2)), }); } return ( [value.toFixed(2), t('grading.trend_title')]} /> ); })()}
)} )} {/* Create scheme dialog */} {t('grading.create_scheme_title')}
setNewName(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />