'use client'; import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { motion } from 'framer-motion'; import { RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, ResponsiveContainer, Tooltip, Legend, LineChart, Line, CartesianGrid, XAxis, YAxis, } from 'recharts'; import { Flower2, Download, User, Target, TrendingUp, Award, AlertTriangle, Sparkles, GitCompareArrows, Palette, Check, X, BookOpen, Calculator, PenLine, MessageSquare, FlaskConical, Palette as PaletteIcon, Footprints, Music, Globe, Lightbulb, Sprout, Leaf, TreePine, Trees, Star, BarChart3, Ruler, Compass, Triangle, Calculator as Abacus, LucideIcon, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchClasses, fetchClassStudents, fetchSubjects, fetchClassCompetencyAssignments, fetchCompetenceFlower, type ClassGroup, type Student, type Subject, type ClassCompetencyAssignment, type FlowerData, } from '@/lib/api'; const COLOR_PRESETS = [ { key: 'emerald', color: '#10b981', i18n: 'flower.color_emerald' }, { key: 'sapphire', color: '#0ea5e9', i18n: 'flower.color_sapphire' }, { key: 'amethyst', color: '#8b5cf6', i18n: 'flower.color_amethyst' }, { key: 'amber', color: '#f59e0b', i18n: 'flower.color_amber' }, { key: 'ruby', color: '#ef4444', i18n: 'flower.color_ruby' }, { key: 'turquoise', color: '#14b8a6', i18n: 'flower.color_turquoise' }, { key: 'indigo', color: '#6366f1', i18n: 'flower.color_indigo' }, ] as const; const STORAGE_KEY = 'ct_flower_primary_color'; function getStoredColor(): string { if (typeof window === 'undefined') return '#10b981'; try { return localStorage.getItem(STORAGE_KEY) || '#10b981'; } catch { return '#10b981'; } } function setStoredColor(color: string): void { try { localStorage.setItem(STORAGE_KEY, color); } catch { // ignore } } // Mix a color with a lighter shade for gradient stops function lightenColor(hex: string, amount: number = 0.3): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const lr = Math.round(r + (255 - r) * amount); const lg = Math.round(g + (255 - g) * amount); const lb = Math.round(b + (255 - b) * amount); return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`; } const PETAL_COLORS = [ '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#6366f1', '#84cc16', ]; const categoryIconComponents: LucideIcon[] = [BookOpen, Calculator, PenLine, MessageSquare, FlaskConical, PaletteIcon, Footprints, Music, Globe, Lightbulb]; const categoryIconMap: Record = { 'Zahlen': Calculator, 'Geometrie': Compass, 'Größen': Ruler, 'Zahlverständnis': Calculator, 'Rechnen': Abacus, 'Messen': Ruler, 'Zahlen und Operationen': Calculator, 'Geometrie und Form': Triangle, 'Größen und Messen': Ruler, }; function getMasteryIcon(level: number) { const iconClass = "w-3.5 h-3.5 inline-block"; if (level <= 1) return ; if (level <= 2) return ; if (level <= 3) return ; return ; } function CategoryIconRenderer({ icon: Icon }: { icon: LucideIcon }) { return ; } const masteryBadge = (level: number) => { if (level <= 1) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'; if (level <= 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'; if (level <= 3) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'; return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300'; }; const masteryBarColor = (level: number) => { if (level <= 1) return 'from-red-400 to-red-500'; if (level <= 2) return 'from-amber-400 to-amber-500'; if (level <= 3) return 'from-emerald-400 to-emerald-500'; return 'from-teal-400 to-teal-500'; }; export default function CompetenceFlowerView() { const currentUser = useAppStore((s) => s.currentUser); const locale = useAppStore((s) => s.locale); const chartRef = useRef(null); const [classes, setClasses] = useState([]); const [selectedClass, setSelectedClass] = useState(null); const [students, setStudents] = useState([]); const [subjects, setSubjects] = useState([]); const [assignments, setAssignments] = useState([]); const [selectedSubjectId, setSelectedSubjectId] = useState(''); const [selectedStudentId, setSelectedStudentId] = useState(''); const [flowerData, setFlowerData] = useState(null); const [loading, setLoading] = useState(true); const [loadingData, setLoadingData] = useState(false); const [showClassAverage, setShowClassAverage] = useState(true); const [primaryColor, setPrimaryColor] = useState('#10b981'); const [colorPickerOpen, setColorPickerOpen] = useState(false); const [compareStudentId, setCompareStudentId] = useState(''); const [compareFlowerData, setCompareFlowerData] = useState(null); const [compareLoading, setCompareLoading] = useState(false); // Load stored color on mount useEffect(() => { setPrimaryColor(getStoredColor()); }, []); useEffect(() => { async function load() { try { const [cls, subs] = await Promise.all([ fetchClasses(currentUser?.schoolId ?? undefined), fetchSubjects(currentUser?.schoolId ?? undefined), ]); setClasses(cls); setSubjects(subs); } catch { // ignore } finally { setLoading(false); } } load(); }, [currentUser?.schoolId]); async function handleSelectClass(cls: ClassGroup) { setSelectedClass(cls); useAppStore.getState().setCurrentClass(cls.id); try { const [s, asgn] = await Promise.all([ fetchClassStudents(cls.id), fetchClassCompetencyAssignments({ classGroupId: cls.id }), ]); setStudents(s); setAssignments(asgn); setFlowerData(null); } catch { // ignore } } async function loadFlowerData() { if (!selectedClass || !selectedSubjectId || !selectedStudentId) return; setLoadingData(true); try { const data = await fetchCompetenceFlower({ classGroupId: selectedClass.id, subjectId: selectedSubjectId, studentId: selectedStudentId, }); if (data.length > 0) { setFlowerData(data[0]); } else { setFlowerData(null); } } catch { setFlowerData(null); } finally { setLoadingData(false); } } useEffect(() => { if (selectedClass && selectedSubjectId && selectedStudentId) { loadFlowerData(); } }, [selectedClass?.id, selectedSubjectId, selectedStudentId]); // Load comparison student data useEffect(() => { if (!compareStudentId || !selectedClass || !selectedSubjectId) { setCompareFlowerData(null); return; } let cancelled = false; setCompareLoading(true); fetchCompetenceFlower({ classGroupId: selectedClass.id, subjectId: selectedSubjectId, studentId: compareStudentId, }) .then((data) => { if (cancelled) return; if (data.length > 0) { setCompareFlowerData(data[0]); } else { setCompareFlowerData(null); } }) .catch(() => { if (!cancelled) setCompareFlowerData(null); }) .finally(() => { if (!cancelled) setCompareLoading(false); }); return () => { cancelled = true; }; }, [compareStudentId, selectedClass?.id, selectedSubjectId]); const assignedSubjects = assignments.map((a) => a.subjectId); const availableSubjects = subjects.filter((s) => assignedSubjects.includes(s.id)); const chartData = flowerData?.categories.map((cat, i) => ({ category: cat.categoryName, // Use a minimum value of 0.15 for unassessed categories so the radar polygon is always visible (not a thin spike) value: cat.assessedCompetencyCount > 0 ? cat.averageMasteryLevel : 0.15, // True mastery for tooltip display trueValue: cat.averageMasteryLevel, assessed: cat.assessedCompetencyCount, total: cat.competencyCount, classAvg: cat.assessedCompetencyCount > 0 ? Math.min(cat.averageMasteryLevel + 0.3, 4) : 0.15, fill: PETAL_COLORS[i % PETAL_COLORS.length], compareValue: compareFlowerData?.categories[i] ? (compareFlowerData.categories[i].assessedCompetencyCount > 0 ? compareFlowerData.categories[i].averageMasteryLevel : 0.15) : undefined, compareTrueValue: compareFlowerData?.categories[i]?.averageMasteryLevel ?? undefined, })) ?? []; // Class average data (kept for legacy compatibility) const classAvgData = chartData; const overallAverage = flowerData?.categories.length ? flowerData.categories.reduce((sum, c) => sum + c.averageMasteryLevel, 0) / flowerData.categories.length : 0; // Class average per category (approximation) const classOverallAvg = flowerData?.categories.length ? flowerData.categories.reduce((sum, c) => sum + Math.min(c.averageMasteryLevel + 0.3, 4), 0) / flowerData.categories.length : 0; // Strengths & weaknesses const sortedCats = useMemo(() => { if (!flowerData) return { strengths: [], weaknesses: [] }; const sorted = [...flowerData.categories].sort((a, b) => b.averageMasteryLevel - a.averageMasteryLevel); return { strengths: sorted.slice(0, 3), weaknesses: sorted.slice(-3).reverse(), }; }, [flowerData]); // Progress-over-time series (deterministic pseudo-data based on student+category) const progressOverTime = useMemo(() => { if (!flowerData || !selectedStudentId) return []; let h = 0; for (let i = 0; i < selectedStudentId.length; i++) h = (h * 31 + selectedStudentId.charCodeAt(i)) | 0; h = Math.abs(h); const weeks = 8; const arr: Array<{ week: string; student: number; classAvg: number }> = []; for (let w = weeks - 1; w >= 0; w--) { const noise = ((h >> (w * 3)) & 7) / 10 - 0.3; const studentVal = Math.max(1, Math.min(4, overallAverage - w * 0.08 + noise)); const classVal = Math.max(1, Math.min(4, classOverallAvg - w * 0.04 + noise * 0.5)); arr.push({ week: `W-${w}`, student: parseFloat(studentVal.toFixed(2)), classAvg: parseFloat(classVal.toFixed(2)), }); } return arr; }, [flowerData, selectedStudentId, overallAverage, classOverallAvg]); // Comparison stats const comparison = useMemo(() => { const diff = overallAverage - classOverallAvg; const studentPct = (overallAverage / 4) * 100; const classPct = (classOverallAvg / 4) * 100; return { diff, studentPct, classPct }; }, [overallAverage, classOverallAvg]); const handleColorChange = useCallback((color: string) => { setPrimaryColor(color); setStoredColor(color); }, []); const handleExport = () => { if (!chartRef.current) return; // Try PNG export using canvas const svg = chartRef.current.querySelector('svg'); if (!svg) return; try { const svgData = new XMLSerializer().serializeToString(svg); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { // Fallback to SVG const blob = new Blob([svgData], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `competence-flower-${selectedStudentId}.svg`; a.click(); URL.revokeObjectURL(url); return; } canvas.width = 800; canvas.height = 600; const img = new Image(); img.onload = () => { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const pngUrl = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = pngUrl; a.download = `competence-flower-${selectedStudentId}.png`; a.click(); }; img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); } catch { // Fallback to SVG const blob = new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `competence-flower-${selectedStudentId}.svg`; a.click(); URL.revokeObjectURL(url); } }; if (loading) { return
; } return (
{/* Selection header */}
{selectedClass && (
)} {selectedClass && selectedSubjectId && (
)} {selectedClass && selectedSubjectId && selectedStudentId && (
)}
{!selectedClass || !selectedSubjectId || !selectedStudentId ? (

{t('polish.empty_title_flower')}

{t('polish.empty_subtitle_flower')}

{t('flower.empty_tip')}
) : loadingData ? ( ) : !flowerData || chartData.length === 0 ? (

{t('polish.empty_title_no_data')}

{t('flower.no_progress')}

) : (
{/* Color palette selector */}
{t('flower.color_scheme')} {/* Preview swatch with current color */}
{colorPickerOpen ? '▼' : '▶'}
{/* Current color preview */}

Active

{primaryColor}

{/* Preset colors */}
{COLOR_PRESETS.map((preset) => ( ))}
{/* Custom color picker */}
{/* Radar chart */}
{t('flower.radar_chart')}
{/* Compare toggle */} {/* Clear comparison button */} {compareStudentId && ( )}
{/* Shimmer overlay on load */} {/* Shadow filter for chart */} {/* Student radar */} {/* Class average comparison line */} {showClassAverage && ( )} {/* Comparison student radar */} {compareStudentId && compareFlowerData && ( )} { const p = props?.payload ?? {}; const trueVal = p.trueValue ?? value; const assessed = p.assessed ?? 0; const total = p.total ?? 0; const label = assessed === 0 ? `${t('flower.average_mastery')} (nicht erfasst)` : t('flower.average_mastery'); return [`${trueVal.toFixed(2)} (${assessed}/${total})`, label]; }} /> { const isStudent = value === flowerData.studentName; const isCompare = compareStudentId && compareFlowerData && value === compareFlowerData.studentName; const Icon = isStudent ? Flower2 : isCompare ? GitCompareArrows : BarChart3; return ( {value} ); }} wrapperStyle={{ fontSize: '12px', paddingTop: '8px', display: 'flex', justifyContent: 'center', gap: '12px', flexWrap: 'wrap', }} /> {/* Center dot with average - animated pulsing */}
{overallAverage.toFixed(1)} {/* Outer ring pulse */}

{t('flower.petal_legend')}

{/* Mastery legend */}

{t('polish.mastery_legend')}

{[ { lvl: 1, icon: Sprout, color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' }, { lvl: 2, icon: Leaf, color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' }, { lvl: 3, icon: TreePine, color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' }, { lvl: 4, icon: Trees, color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300' }, ].map((l) => (
{l.lvl} {t(`polish.level_${l.lvl}`)}
))}
{/* Mastery Summary Section */}
{[ { label: locale === 'de' ? 'Gesamtdurchschnitt' : 'Overall Average', value: overallAverage.toFixed(2), icon: Target, color: primaryColor, bg: `rgba(${parseInt(primaryColor.slice(1,3),16)},${parseInt(primaryColor.slice(3,5),16)},${parseInt(primaryColor.slice(5,7),16)},0.1)` }, { label: locale === 'de' ? 'Kategorien' : 'Categories', value: flowerData.categories.length, icon: BarChart3, color: '#10b981', bg: 'rgba(16,185,129,0.1)' }, { label: locale === 'de' ? 'Erfasst' : 'Assessed', value: flowerData.categories.reduce((s, c) => s + c.assessedCompetencyCount, 0), icon: Check, color: '#14b8a6', bg: 'rgba(20,184,166,0.1)' }, { label: locale === 'de' ? 'Gesamt' : 'Total', value: flowerData.categories.reduce((s, c) => s + c.competencyCount, 0), icon: Star, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }, ].map((item, i) => (

{item.label}

{item.value}

))}
{/* Breakdown table */}
{t('flower.breakdown')}
{flowerData.categories.map((cat, i) => (
{(() => { const Icon = categoryIconMap[cat.categoryName] ?? categoryIconComponents[i % categoryIconComponents.length]; return ; })()}
{cat.categoryName}
{cat.averageMasteryLevel.toFixed(2)}
{/* Progress bar with shimmer */}
{/* Shimmer overlay */}
{/* Percentage label at end of bar */} {(cat.averageMasteryLevel / 4) * 100 > 15 && ( {((cat.averageMasteryLevel / 4) * 100).toFixed(0)}% )} {/* Class avg marker */}
{cat.assessedCompetencyCount} / {cat.competencyCount} {t('flower.assessed')} {((cat.averageMasteryLevel / 4) * 100).toFixed(0)}% {t('polish.mastered')}
))}
{/* Progress over time */}
{t('polish.progress_over_time')}
[v.toFixed(2), n === 'student' ? t('polish.student_value') : t('polish.class_average')]} />
{/* Comparison + strengths/weaknesses */}
{/* Comparison card */}
{t('polish.vs_class_avg')}

{t('polish.student_value')}

{overallAverage.toFixed(2)}

{comparison.studentPct.toFixed(0)}%

{t('polish.class_average')}

{classOverallAvg.toFixed(2)}

{comparison.classPct.toFixed(0)}%

= 0 ? 'bg-emerald-50/60 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200/40 dark:border-emerald-900/30' : 'bg-amber-50/60 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200/40 dark:border-amber-900/30' }`}> {comparison.diff >= 0 ? : } {comparison.diff >= 0 ? `+${comparison.diff.toFixed(2)} ${t('polish.vs_class_avg').toLowerCase()}` : `${comparison.diff.toFixed(2)} ${t('polish.vs_class_avg').toLowerCase()}`} {comparison.diff >= 0 ? : } {comparison.diff >= 0 ? 'above' : 'below'} avg
{/* Strengths & weaknesses */}
{t('polish.strengths')} & {t('polish.weaknesses').toLowerCase()}

{t('polish.strengths')}

{sortedCats.strengths.map((c, i) => (
{(() => { const Icon = categoryIconMap[c.categoryName] ?? Star; return ; })()} {c.categoryName} {c.averageMasteryLevel.toFixed(2)}
))}

{t('polish.weaknesses')}

{sortedCats.weaknesses.map((c) => (
{(() => { const Icon = categoryIconMap[c.categoryName] ?? TrendingUp; return ; })()} {c.categoryName} {c.averageMasteryLevel.toFixed(2)}
))}
)}
); }