'use client'; import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { motion } from 'framer-motion'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer, ReferenceLine, Tooltip as RechartsTooltip, } from 'recharts'; import { ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home, Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles, PenTool, GripVertical, ChevronLeft, ChevronRight, } 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, DialogDescription } 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 { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; import DrawingCanvas from '@/components/drawing-canvas'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import StudentAvatar from '@/components/student-avatar'; import { fetchClasses, fetchClassStudents, fetchSubjects, fetchAssessments, createAssessment, fetchAssessmentResults, createAssessmentResults, fetchClassCompetencyAssignments, fetchCompetencyTemplate, downloadCsvExport, addNotification, type ClassGroup, type Student, type Subject, type Assessment, type AssessmentResult, type ClassCompetencyAssignment, type CompetencyTemplate, } from '@/lib/api'; import { toast } from 'sonner'; function hashStr(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h); } // Pseudo class average score for an assessment (deterministic, in range 60-90% of maxScore) function pseudoClassAvg(a: Assessment): number | null { if (!a.maxScore) return null; const pct = 0.6 + (hashStr(a.id) % 30) / 100; // 0.6 - 0.9 return Math.round(a.maxScore * pct * 10) / 10; } // Pseudo score distribution across 5 buckets (0-20%, 20-40%, ..., 80-100%) function pseudoScoreDistribution(a: Assessment): number[] { const h = hashStr(a.id); const buckets = [ 1 + (h % 4), 2 + ((h >> 3) % 5), 4 + ((h >> 6) % 6), 3 + ((h >> 9) % 5), 1 + ((h >> 12) % 4), ]; return buckets; } // Compute best performer & needs support from entered scores function useBestAndNeedsSupport( students: Student[], gradingScores: Record, maxScore: number | null ) { return useMemo(() => { if (!maxScore) return { bestPerformerId: null, needsSupportId: null }; let bestId: string | null = null; let bestRatio = -1; let lowId: string | null = null; let lowRatio = 2; for (const s of students) { const raw = gradingScores[s.id]?.score; if (!raw) continue; const v = parseFloat(raw); if (isNaN(v)) continue; const r = v / maxScore; if (r > bestRatio) { bestRatio = r; bestId = s.id; } if (r < lowRatio) { lowRatio = r; lowId = s.id; } } if (bestId && lowId && bestId === lowId) return { bestPerformerId: bestId, needsSupportId: null }; return { bestPerformerId: bestId, needsSupportId: lowId }; }, [students, gradingScores, maxScore]); } const assessmentTypes = ['TEST', 'ORAL', 'PROJECT', 'HOMEWORK', 'OTHER']; const typeConfig: Record = { TEST: { icon: FileText, color: 'text-emerald-700 dark:text-emerald-300', bg: 'bg-emerald-100 dark:bg-emerald-900/30', border: 'border-l-emerald-500', gradient: 'from-emerald-500 to-teal-500' }, ORAL: { icon: Mic, color: 'text-amber-700 dark:text-amber-300', bg: 'bg-amber-100 dark:bg-amber-900/30', border: 'border-l-amber-500', gradient: 'from-amber-500 to-orange-500' }, PROJECT: { icon: FolderOpen, color: 'text-violet-700 dark:text-violet-300', bg: 'bg-violet-100 dark:bg-violet-900/30', border: 'border-l-violet-500', gradient: 'from-violet-500 to-purple-500' }, HOMEWORK: { icon: Home, color: 'text-teal-700 dark:text-teal-300', bg: 'bg-teal-100 dark:bg-teal-900/30', border: 'border-l-teal-500', gradient: 'from-teal-500 to-cyan-500' }, OTHER: { icon: ClipboardCheck, color: 'text-gray-700 dark:text-gray-300', bg: 'bg-gray-100 dark:bg-gray-900/30', border: 'border-l-gray-500', gradient: 'from-gray-500 to-gray-600' }, }; const relativeDate = (dateStr: string) => { const now = new Date(); const date = new Date(dateStr); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return t('date.today'); if (diffDays === 1) return t('date.yesterday'); if (diffDays < 7) return t('date.days_ago', { count: diffDays }); if (diffDays < 30) return t('date.weeks_ago', { count: Math.floor(diffDays / 7) }); return date.toLocaleDateString(); }; const resultCountBadge = (count: number) => { if (count === 0) return 'grade-badge-below'; if (count < 5) return 'grade-badge-average'; return 'grade-badge-excellent'; }; // Emerald gradient colors for score distribution bars const EMERALD_BAR_COLORS = [ '#a7f3d0', // emerald-200 '#6ee7b7', // emerald-300 '#34d399', // emerald-400 '#10b981', // emerald-500 '#059669', // emerald-600 ]; function ScoreDistributionHistogram({ assessment, results }: { assessment: Assessment; results: AssessmentResult[] }) { const maxScore = assessment.maxScore; if (!maxScore || results.length === 0) return null; // Extract numeric scores const scores = results.map((r) => r.score).filter((s): s is number => s !== null && s !== undefined); if (scores.length === 0) return null; // Create dynamic bucket ranges based on maxScore const bucketCount = Math.min(Math.max(Math.ceil(maxScore / 5), 4), 8); const bucketSize = maxScore / bucketCount; const buckets = Array.from({ length: bucketCount }, (_, i) => { const low = Math.round(i * bucketSize * 10) / 10; const high = Math.round((i + 1) * bucketSize * 10) / 10; return { range: `${low}-${high}`, low, high, count: 0 }; }); for (const s of scores) { const idx = Math.min(Math.floor(s / bucketSize), bucketCount - 1); buckets[idx].count++; } const avg = scores.reduce((a, b) => a + b, 0) / scores.length; const sorted = [...scores].sort((a, b) => a - b); const median = sorted.length % 2 === 0 ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 : sorted[Math.floor(sorted.length / 2)]; const min = sorted[0]; const max = sorted[sorted.length - 1]; return (

{t('assessments.score_distribution')}

{t('assessments.average')}: {avg.toFixed(1)} · {t('assessments.median')}: {median.toFixed(1)} · {t('assessments.range')}: {min.toFixed(0)}-{max.toFixed(0)}

[value, t('assessments.score_distribution')]} /> {buckets.map((_, index) => ( ))}
); }; export default function AssessmentsView() { 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 [assessments, setAssessments] = useState([]); const [students, setStudents] = useState([]); const [loading, setLoading] = useState(true); // Create dialog const [createOpen, setCreateOpen] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]); const [newType, setNewType] = useState('TEST'); const [newMaxScore, setNewMaxScore] = useState(''); const [newWeight, setNewWeight] = useState('1.0'); const [creating, setCreating] = useState(false); // Grading dialog const [gradingAssessment, setGradingAssessment] = useState(null); const [gradingResults, setGradingResults] = useState([]); const [gradingScores, setGradingScores] = useState>({}); const [gradingOpen, setGradingOpen] = useState(false); const [savingScores, setSavingScores] = useState(false); // Best performer & needs support in the grading grid const { bestPerformerId, needsSupportId } = useBestAndNeedsSupport( students, gradingScores, gradingAssessment?.maxScore ?? null ); // Scratch pad state const [scratchPadOpen, setScratchPadOpen] = useState(false); const [scratchPadWidth, setScratchPadWidth] = useState(480); const [scratchPadDrawingData, setScratchPadDrawingData] = useState(null); const [scratchPadDrawingId, setScratchPadDrawingId] = useState(null); const [scratchPadSaving, setScratchPadSaving] = useState(false); const scratchPadResizeRef = useRef<{ startX: number; startWidth: number } | null>(null); // Load existing scratch pad drawing for the current assessment const loadScratchPadDrawing = useCallback(async (assessmentId: string) => { try { const res = await fetch(`/api/drawings?subjectId=${assessmentId}`); if (res.ok) { const drawings = await res.json(); // Find a scratch pad drawing for this assessment const scratchPad = drawings.find((d: { title: string; description: string }) => d.title?.startsWith('Scratch Pad:') || d.description?.startsWith('assessment-scratch-pad:') ); if (scratchPad) { setScratchPadDrawingId(scratchPad.id); setScratchPadDrawingData(scratchPad.drawingData); } else { setScratchPadDrawingId(null); setScratchPadDrawingData(null); } } } catch { // ignore } }, []); // Save scratch pad drawing const handleSaveScratchPad = useCallback(async (drawingData: string, imageData: string) => { if (!gradingAssessment) return; setScratchPadSaving(true); try { const title = `Scratch Pad: ${gradingAssessment.title}`; const description = `assessment-scratch-pad:${gradingAssessment.id}`; if (scratchPadDrawingId) { // Update existing drawing await fetch(`/api/drawings/${scratchPadDrawingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ drawingData, imageData, title }), }); } else { // Create new drawing const res = await fetch('/api/drawings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, drawingData, imageData, subjectId: gradingAssessment.subjectId ?? undefined, classGroupId: gradingAssessment.classGroupId ?? undefined, schoolId: currentUser?.schoolId ?? undefined, isPublic: false, }), }); if (res.ok) { const created = await res.json(); setScratchPadDrawingId(created.id); } } toast.success(t('assessments.scratch_pad_saved')); } catch { toast.error(t('error.generic')); } finally { setScratchPadSaving(false); } }, [gradingAssessment, scratchPadDrawingId, currentUser?.schoolId]); // Handle mouse move for resizing const handleResizeMouseMove = useCallback((e: MouseEvent) => { if (!scratchPadResizeRef.current) return; const delta = scratchPadResizeRef.current.startX - e.clientX; const newWidth = Math.min(Math.max(scratchPadResizeRef.current.startWidth + delta, 320), window.innerWidth * 0.7); setScratchPadWidth(newWidth); }, []); const handleResizeMouseUp = useCallback(() => { scratchPadResizeRef.current = null; document.removeEventListener('mousemove', handleResizeMouseMove); document.removeEventListener('mouseup', handleResizeMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }, [handleResizeMouseMove]); const startResize = useCallback((e: React.MouseEvent) => { e.preventDefault(); scratchPadResizeRef.current = { startX: e.clientX, startWidth: scratchPadWidth }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', handleResizeMouseMove); document.addEventListener('mouseup', handleResizeMouseUp); }, [scratchPadWidth, handleResizeMouseMove, handleResizeMouseUp]); 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]); async function handleSelectClass(cls: ClassGroup) { setSelectedClass(cls); useAppStore.getState().setCurrentClass(cls.id); try { const [s] = await Promise.all([ fetchClassStudents(cls.id), ]); setStudents(s); if (selectedSubjectId) loadAssessments(cls.id, selectedSubjectId); } catch { // ignore } } async function loadAssessments(classGroupId: string, subjectId: string) { try { const a = await fetchAssessments({ classGroupId, subjectId }); setAssessments(a); } catch { setAssessments([]); } } const handleSelectSubject = async (subjectId: string) => { setSelectedSubjectId(subjectId); if (selectedClass) { await loadAssessments(selectedClass.id, subjectId); } }; const handleCreateAssessment = async () => { if (!selectedClass || !currentUser || !selectedSubjectId) return; setCreating(true); try { await createAssessment({ classGroupId: selectedClass.id, subjectId: selectedSubjectId, teacherId: currentUser.id, title: newTitle, date: newDate, type: newType, maxScore: newMaxScore ? parseFloat(newMaxScore) : null, weight: parseFloat(newWeight), }); toast.success(t('toast.created')); addNotification({ type: 'assessment', message: `${t('notification.assessment_created')}: ${newTitle}`, timestamp: new Date().toISOString(), }); setCreateOpen(false); setNewTitle(''); setNewDate(new Date().toISOString().split('T')[0]); setNewType('TEST'); setNewMaxScore(''); setNewWeight('1.0'); loadAssessments(selectedClass.id, selectedSubjectId); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } finally { setCreating(false); } }; const openGrading = async (assessment: Assessment) => { setGradingAssessment(assessment); setGradingOpen(true); setScratchPadOpen(false); setScratchPadDrawingId(null); setScratchPadDrawingData(null); try { const results = await fetchAssessmentResults(assessment.id); setGradingResults(results); const scoresMap: Record = {}; for (const student of students) { const existing = results.find((r) => r.studentId === student.id); scoresMap[student.id] = { score: existing?.score != null ? String(existing.score) : '', masteryLevel: existing?.masteryLevelValue != null ? String(existing.masteryLevelValue) : '', note: existing?.note ?? '', }; } setGradingScores(scoresMap); // Load existing scratch pad drawing loadScratchPadDrawing(assessment.id); } catch { setGradingResults([]); } }; const handleSaveScores = async () => { if (!gradingAssessment) return; setSavingScores(true); try { const data = students.map((s) => ({ studentId: s.id, score: gradingScores[s.id]?.score ? parseFloat(gradingScores[s.id].score) : null, masteryLevelValue: gradingScores[s.id]?.masteryLevel ? parseInt(gradingScores[s.id].masteryLevel) : null, note: gradingScores[s.id]?.note || undefined, })).filter((d) => d.score !== null || d.masteryLevelValue !== null); if (data.length > 0) { await createAssessmentResults(gradingAssessment.id, data); } toast.success(t('assessments.scores_saved')); setGradingOpen(false); if (selectedClass && selectedSubjectId) { loadAssessments(selectedClass.id, selectedSubjectId); } } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } finally { setSavingScores(false); } }; if (loading) { return
; } return (
{/* Selection header */}
{selectedClass && (
)} {selectedClass && selectedSubjectId && ( )}
{/* Assessments list */} {!selectedClass || !selectedSubjectId ? (

{t('polish.empty_title_assessments')}

{t('polish.empty_subtitle_assessments')}

{t('assessments.select_subject')}
) : assessments.length === 0 ? (

{t('polish.empty_title_no_data')}

{t('assessments.no_assessments')}

) : (
{t('assessments.list')} {assessments.length}
{t('label.name')} {t('assessments.assessment_type')} {t('label.date')} {t('assessments.max_score')} {t('assessments.weight')} {t('assessments.results_count')} {t('polish.score_distribution')} {t('assessments.enter_scores')} {assessments.map((a) => { const tc = typeConfig[a.type] ?? typeConfig.OTHER; const TypeIcon = tc.icon; const classAvg = pseudoClassAvg(a); const dist = pseudoScoreDistribution(a); const distMax = Math.max(...dist, 1); return (
{a.title}
{t(`assessment_type.${a.type.toLowerCase()}`)}
{relativeDate(a.date)}
{new Date(a.date).toLocaleDateString()}
{a.maxScore ?? '—'} {a.weight}
{a._count.assessmentResults} {classAvg !== null && ( Ø {classAvg} )}
{dist.map((v, i) => (
))}
); })}
)} {/* Create assessment dialog */}
{(() => { const Icon = typeConfig[newType]?.icon ?? ClipboardCheck; return ; })()}
{t('assessments.create_title')}
{/* Step indicator */}
1
{t('label.name')}
2
{t('assessments.assessment_type')}
3
{t('assessments.max_score')}
setNewTitle(e.target.value)} placeholder={t('assessments.create_title')} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
setNewDate(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
setNewMaxScore(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
setNewWeight(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
{/* Grading dialog */} {t('assessments.bulk_grading_title', { title: gradingAssessment?.title ?? '' })} {gradingAssessment && ( {t('polish.class_avg')}: {pseudoClassAvg(gradingAssessment) ?? '—'} {t('label.max_score')}: {gradingAssessment.maxScore ?? '—'} )}
{t('grading.student')} {gradingAssessment?.maxScore && ( {t('label.score')} (max {gradingAssessment.maxScore}) )} {t('label.level')} (1–4) {t('label.note')} {students.map((s, idx) => { const scoreVal = gradingScores[s.id]?.score ? parseFloat(gradingScores[s.id]!.score) : null; const maxScore = gradingAssessment?.maxScore ?? null; const ratio = scoreVal !== null && maxScore ? scoreVal / maxScore : null; const scoreCellBg = ratio === null ? '' : ratio >= 0.85 ? 'bg-emerald-50 dark:bg-emerald-900/20' : ratio >= 0.65 ? 'bg-teal-50 dark:bg-teal-900/15' : ratio >= 0.45 ? 'bg-amber-50 dark:bg-amber-900/15' : 'bg-red-50 dark:bg-red-900/20'; const isBest = scoreVal !== null && maxScore && bestPerformerId === s.id; const needsSupport = scoreVal !== null && maxScore && needsSupportId === s.id; return (
{s.firstName} {s.lastName} {isBest && ( {t('polish.best_performer')} )} {needsSupport && ( {t('polish.needs_support')} )}
{gradingAssessment?.maxScore && ( setGradingScores({ ...gradingScores, [s.id]: { ...gradingScores[s.id], score: e.target.value }, })} /> )} setGradingScores({ ...gradingScores, [s.id]: { ...gradingScores[s.id], note: e.target.value }, })} />
); })}
{/* Score Distribution Histogram */} {gradingAssessment && gradingResults.length > 0 && ( )}
{/* Scratch Pad Sheet */} {/* Resize handle */}
{t('assessments.scratch_pad_title', { title: gradingAssessment?.title ?? '' })}
{t('assessments.scratch_pad_description')}
{scratchPadSaving && (
{t('assessments.scratch_pad_saving')}
)}
); }