- Fixed attendance API 403: added TEACHER school membership check - Fixed competitions API 403: cross-school access, null schoolId, public fallback - Fixed GDPR export API 500: 16 Prisma field name mismatches - Enhanced notebooks: page templates (Cornell, Mind Map, Venn, T-Chart, Weekly Planner), sticky notes, search, page duplication, sections/chapters, highlighter tool - Added scratch pad with DrawingCanvas in assessments for hand-written solutions - Role-based dashboard: TEACHER (competence radar, upcoming lessons), STUDENT (grades trend, competitions), PARENT (children grades, calendar), SCHOOL_ADMIN (system health, audit log, user management) - Digital reward system: 16 demo rewards, points earning/redemption, categories (streaming, shopping, experiences, merchandise, privileges) - GDPR compliance: consent management, data retention, cookie consent - Deep styling polish: glassmorphism, gradient headers, animated sidebar, card hover effects, progress bar animations, timeline dots, FAB button - 160+ new i18n keys (DE/EN)
997 lines
50 KiB
TypeScript
997 lines
50 KiB
TypeScript
'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<string, { score: string; masteryLevel: string; note: string }>,
|
||
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<string, { icon: React.ElementType; color: string; bg: string; border: string; gradient: string }> = {
|
||
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 (
|
||
<div className="mt-4 p-5 rounded-xl bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5 border border-emerald-200/30 dark:border-emerald-900/20 shadow-sm">
|
||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||
<p className="text-xs font-semibold text-emerald-600/60 dark:text-emerald-400/40 uppercase tracking-wider flex items-center gap-1.5">
|
||
<BarChart3 className="h-3.5 w-3.5" />
|
||
{t('assessments.score_distribution')}
|
||
</p>
|
||
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50">
|
||
{t('assessments.average')}: <strong>{avg.toFixed(1)}</strong> · {t('assessments.median')}: <strong>{median.toFixed(1)}</strong> · {t('assessments.range')}: <strong>{min.toFixed(0)}-{max.toFixed(0)}</strong>
|
||
</p>
|
||
</div>
|
||
<div className="w-full h-44 sm:h-52">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<BarChart data={buckets} margin={{ top: 5, right: 5, bottom: 5, left: -10 }}>
|
||
<defs>
|
||
<linearGradient id="scoreBarGradient" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#10b981" stopOpacity={0.9} />
|
||
<stop offset="100%" stopColor="#14b8a6" stopOpacity={0.6} />
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.90 0.01 155)" className="dark:stroke-gray-700/30" vertical={false} />
|
||
<XAxis
|
||
dataKey="range"
|
||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||
axisLine={{ stroke: '#d1d5db' }}
|
||
tickLine={{ stroke: '#d1d5db' }}
|
||
/>
|
||
<YAxis
|
||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||
axisLine={{ stroke: '#d1d5db' }}
|
||
tickLine={{ stroke: '#d1d5db' }}
|
||
allowDecimals={false}
|
||
/>
|
||
<RechartsTooltip
|
||
contentStyle={{
|
||
borderRadius: '12px',
|
||
border: '1px solid #e5e7eb',
|
||
fontSize: '12px',
|
||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||
}}
|
||
formatter={(value: number) => [value, t('assessments.score_distribution')]}
|
||
/>
|
||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
|
||
{buckets.map((_, index) => (
|
||
<Cell key={index} fill="url(#scoreBarGradient)" />
|
||
))}
|
||
</Bar>
|
||
<ReferenceLine
|
||
x={avg}
|
||
stroke="#10b981"
|
||
strokeWidth={2}
|
||
strokeDasharray="6 3"
|
||
label={{
|
||
value: `Ø ${avg.toFixed(1)}`,
|
||
position: 'top',
|
||
fill: '#10b981',
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
}}
|
||
/>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default function AssessmentsView() {
|
||
const currentUser = useAppStore((s) => s.currentUser);
|
||
const currentClassId = useAppStore((s) => s.currentClassId);
|
||
|
||
const [classes, setClasses] = useState<ClassGroup[]>([]);
|
||
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
|
||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
|
||
const [assessments, setAssessments] = useState<Assessment[]>([]);
|
||
const [students, setStudents] = useState<Student[]>([]);
|
||
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<Assessment | null>(null);
|
||
const [gradingResults, setGradingResults] = useState<AssessmentResult[]>([]);
|
||
const [gradingScores, setGradingScores] = useState<Record<string, { score: string; masteryLevel: string; note: string }>>({});
|
||
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<string | null>(null);
|
||
const [scratchPadDrawingId, setScratchPadDrawingId] = useState<string | null>(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<string, { score: string; masteryLevel: string; note: string }> = {};
|
||
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 <div className="space-y-4"><Skeleton className="h-96 rounded-xl" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Selection header */}
|
||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||
<CardContent className="p-6">
|
||
<div className="flex flex-col sm:flex-row flex-wrap gap-4 items-end">
|
||
<div className="space-y-1 min-w-[160px] sm:min-w-[200px]">
|
||
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('polish.label_class')}</Label>
|
||
<Select
|
||
value={selectedClass?.id ?? ''}
|
||
onValueChange={(id) => {
|
||
const cls = classes.find((c) => c.id === id);
|
||
if (cls) handleSelectClass(cls);
|
||
}}
|
||
>
|
||
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
|
||
<SelectValue placeholder={t('polish.please_choose')} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{classes.map((c) => (
|
||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{selectedClass && (
|
||
<div className="space-y-1 min-w-[160px] sm:min-w-[200px]">
|
||
<Label className="text-sm font-medium text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.select_subject')}</Label>
|
||
<Select value={selectedSubjectId} onValueChange={handleSelectSubject}>
|
||
<SelectTrigger className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30">
|
||
<SelectValue placeholder={t('assessments.select_subject')} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{subjects.map((s) => (
|
||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{selectedClass && selectedSubjectId && (
|
||
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
|
||
<Plus className="h-4 w-4 mr-1" />
|
||
{t('assessments.create')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Assessments list */}
|
||
{!selectedClass || !selectedSubjectId ? (
|
||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
|
||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||
<CardContent className="py-16 text-center">
|
||
<motion.div
|
||
initial={{ scale: 0.8, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.1, duration: 0.3 }}
|
||
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
|
||
>
|
||
<ClipboardCheck className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
|
||
</motion.div>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_assessments')}</h3>
|
||
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('polish.empty_subtitle_assessments')}</p>
|
||
<div className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50/60 dark:bg-emerald-900/20 border border-emerald-200/40 dark:border-emerald-900/30 text-xs text-emerald-700 dark:text-emerald-300">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
{t('assessments.select_subject')}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
) : assessments.length === 0 ? (
|
||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
|
||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||
<CardContent className="py-16 text-center">
|
||
<motion.div
|
||
initial={{ scale: 0.8, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ delay: 0.1, duration: 0.3 }}
|
||
className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20"
|
||
>
|
||
<ClipboardCheck className="h-10 w-10 text-emerald-500 dark:text-emerald-400" />
|
||
</motion.div>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_no_data')}</h3>
|
||
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('assessments.no_assessments')}</p>
|
||
<Button className="mt-5 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={() => setCreateOpen(true)}>
|
||
<Plus className="h-4 w-4 mr-1" />
|
||
{t('assessments.create')}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
) : (
|
||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||
<ClipboardCheck className="h-4 w-4" />
|
||
</div>
|
||
{t('assessments.list')}
|
||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium rounded-xl">{assessments.length}</Badge>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="max-h-[70vh] overflow-y-auto scrollbar-education">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="border-b border-emerald-200/30 dark:border-emerald-900/20">
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.name')}</TableHead>
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.assessment_type')}</TableHead>
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.date')}</TableHead>
|
||
<TableHead className="hidden sm:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.max_score')}</TableHead>
|
||
<TableHead className="hidden sm:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.weight')}</TableHead>
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.results_count')}</TableHead>
|
||
<TableHead className="hidden lg:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('polish.score_distribution')}</TableHead>
|
||
<TableHead className="text-right text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('assessments.enter_scores')}</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{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 (
|
||
<TableRow key={a.id} className={`hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${a._count.assessmentResults % 2 === 1 ? 'bg-emerald-50/15 dark:bg-emerald-900/5' : ''}`}>
|
||
<TableCell className="font-semibold">
|
||
<div className="flex items-center gap-2">
|
||
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${tc.gradient} text-white shadow-sm shrink-0`}>
|
||
<TypeIcon className="w-3.5 h-3.5" />
|
||
</div>
|
||
<span>{a.title}</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge className={`${tc.bg} ${tc.color} text-xs font-medium rounded-xl border-l-2 ${tc.border}`}>
|
||
<tc.icon className="mr-1 w-3.5 h-3.5 inline" />
|
||
{t(`assessment_type.${a.type.toLowerCase()}`)}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||
{relativeDate(a.date)}
|
||
</div>
|
||
<div className="text-xs text-gray-400">
|
||
{new Date(a.date).toLocaleDateString()}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="hidden sm:table-cell text-gray-500">
|
||
{a.maxScore ?? '—'}
|
||
</TableCell>
|
||
<TableCell className="hidden sm:table-cell text-gray-500">
|
||
{a.weight}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex flex-col gap-1">
|
||
<Badge className={`${resultCountBadge(a._count.assessmentResults)} text-xs font-semibold rounded-xl w-fit`}>
|
||
{a._count.assessmentResults}
|
||
</Badge>
|
||
{classAvg !== null && (
|
||
<span className="text-[10px] text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||
<TrendingUp className="h-2.5 w-2.5 text-emerald-500" />
|
||
Ø {classAvg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="hidden lg:table-cell">
|
||
<div className="flex items-end gap-0.5 h-8 w-24">
|
||
{dist.map((v, i) => (
|
||
<div
|
||
key={i}
|
||
className={`flex-1 rounded-t-sm transition-all hover:brightness-110 ${
|
||
i === 0 ? 'bg-red-400 dark:bg-red-500'
|
||
: i === 1 ? 'bg-amber-400 dark:bg-amber-500'
|
||
: i === 2 ? 'bg-amber-400 dark:bg-amber-500'
|
||
: i === 3 ? 'bg-emerald-400 dark:bg-emerald-500'
|
||
: 'bg-teal-400 dark:bg-teal-500'
|
||
}`}
|
||
style={{ height: `${(v / distMax) * 100}%` }}
|
||
title={`${i * 20}-${(i + 1) * 20}%: ${v}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="min-h-[44px] min-w-[44px] rounded-xl text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
|
||
onClick={() => {
|
||
setGradingAssessment(a);
|
||
setScratchPadOpen(true);
|
||
loadScratchPadDrawing(a.id);
|
||
}}
|
||
title={t('assessments.scratch_pad_open')}
|
||
>
|
||
<PenTool className="h-4 w-4" />
|
||
<span className="sr-only sm:not-sr-only sm:ml-1.5 text-xs">{t('assessments.scratch_pad')}</span>
|
||
</Button>
|
||
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 min-h-[44px] min-w-[44px]" onClick={() => openGrading(a)}>
|
||
<PenLine className="h-3 w-3 mr-1" />
|
||
{t('assessments.enter_scores')}
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Create assessment dialog */}
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||
<DialogContent className="rounded-xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-lg font-bold flex items-center gap-2">
|
||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br ${typeConfig[newType]?.gradient ?? 'from-emerald-500 to-teal-500'} text-white shadow-sm`}>
|
||
{(() => { const Icon = typeConfig[newType]?.icon ?? ClipboardCheck; return <Icon className="h-4 w-4" />; })()}
|
||
</div>
|
||
{t('assessments.create_title')}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{/* Step indicator */}
|
||
<div className="flex items-center gap-2 px-1">
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-[10px] font-bold shadow-sm">1</div>
|
||
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{t('label.name')}</span>
|
||
</div>
|
||
<div className="flex-1 h-0.5 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-[10px] font-bold shadow-sm">2</div>
|
||
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{t('assessments.assessment_type')}</span>
|
||
</div>
|
||
<div className="flex-1 h-0.5 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-6 h-6 rounded-full bg-emerald-500 text-white flex items-center justify-center text-[10px] font-bold shadow-sm">3</div>
|
||
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{t('assessments.max_score')}</span>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-5 pt-2">
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">{t('label.name')}</Label>
|
||
<Input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} placeholder={t('assessments.create_title')} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">{t('assessments.assessment_type')}</Label>
|
||
<Select value={newType} onValueChange={setNewType}>
|
||
<SelectTrigger className="rounded-xl">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{assessmentTypes.map((at) => {
|
||
const tc = typeConfig[at] ?? typeConfig.OTHER;
|
||
return (
|
||
<SelectItem key={at} value={at}>
|
||
<tc.icon className="mr-1 w-3.5 h-3.5 inline" /> {t(`assessment_type.${at.toLowerCase()}`)}
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">{t('label.date')}</Label>
|
||
<Input type="date" value={newDate} onChange={(e) => setNewDate(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">{t('assessments.max_score')}</Label>
|
||
<Input type="number" value={newMaxScore} onChange={(e) => setNewMaxScore(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">{t('assessments.weight')}</Label>
|
||
<Input type="number" value={newWeight} onChange={(e) => setNewWeight(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
|
||
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleCreateAssessment} disabled={creating || !newTitle}>
|
||
{creating ? t('empty.loading') : t('action.create')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Grading dialog */}
|
||
<Dialog open={gradingOpen} onOpenChange={setGradingOpen}>
|
||
<DialogContent className="max-w-3xl rounded-xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-lg font-bold">{t('assessments.bulk_grading_title', { title: gradingAssessment?.title ?? '' })}</DialogTitle>
|
||
{gradingAssessment && (
|
||
<DialogDescription className="flex flex-wrap items-center gap-3 text-xs">
|
||
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
||
<TrendingUp className="h-3 w-3" />
|
||
{t('polish.class_avg')}: <strong className="font-bold">{pseudoClassAvg(gradingAssessment) ?? '—'}</strong>
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||
<BarChart3 className="h-3 w-3" />
|
||
{t('label.max_score')}: <strong>{gradingAssessment.maxScore ?? '—'}</strong>
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="ml-auto min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
|
||
onClick={() => setScratchPadOpen(true)}
|
||
>
|
||
<PenTool className="h-4 w-4 mr-1.5" />
|
||
{t('assessments.scratch_pad')}
|
||
</Button>
|
||
</DialogDescription>
|
||
)}
|
||
</DialogHeader>
|
||
<div className="max-h-[55vh] overflow-y-auto scrollbar-education">
|
||
<Table className="sticky-header">
|
||
<TableHeader>
|
||
<TableRow className="border-b border-emerald-200/30 dark:border-emerald-900/20">
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('grading.student')}</TableHead>
|
||
{gradingAssessment?.maxScore && (
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.score')} (max {gradingAssessment.maxScore})</TableHead>
|
||
)}
|
||
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.level')} (1–4)</TableHead>
|
||
<TableHead className="hidden sm:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.note')}</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{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 (
|
||
<TableRow
|
||
key={s.id}
|
||
className={`hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 transition-all duration-200 ${idx % 2 === 1 ? 'bg-emerald-50/15 dark:bg-emerald-900/5' : 'bg-white dark:bg-gray-900'} ${isBest ? '!bg-emerald-100/60 dark:!bg-emerald-900/30 ring-1 ring-emerald-300 dark:ring-emerald-700' : ''} ${needsSupport ? '!bg-amber-100/40 dark:!bg-amber-900/20 ring-1 ring-amber-300 dark:ring-amber-700' : ''}`}
|
||
>
|
||
<TableCell className="font-semibold">
|
||
<div className="flex items-center gap-2">
|
||
<StudentAvatar firstName={s.firstName} lastName={s.lastName} avatarUrl={s.avatarUrl} size="xs" />
|
||
<span>{s.firstName} {s.lastName}</span>
|
||
{isBest && (
|
||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300 text-[10px]">
|
||
<Trophy className="h-2.5 w-2.5 mr-0.5" /> {t('polish.best_performer')}
|
||
</Badge>
|
||
)}
|
||
{needsSupport && (
|
||
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 text-[10px]">
|
||
<Heart className="h-2.5 w-2.5 mr-0.5" /> {t('polish.needs_support')}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
{gradingAssessment?.maxScore && (
|
||
<TableCell className={scoreCellBg}>
|
||
<Input
|
||
type="number"
|
||
className="h-8 w-20 rounded-xl border-emerald-200/50 dark:border-emerald-900/30"
|
||
value={gradingScores[s.id]?.score ?? ''}
|
||
onChange={(e) => setGradingScores({
|
||
...gradingScores,
|
||
[s.id]: { ...gradingScores[s.id], score: e.target.value },
|
||
})}
|
||
/>
|
||
</TableCell>
|
||
)}
|
||
<TableCell>
|
||
<Select
|
||
value={gradingScores[s.id]?.masteryLevel ?? ''}
|
||
onValueChange={(v) => setGradingScores({
|
||
...gradingScores,
|
||
[s.id]: { ...gradingScores[s.id], masteryLevel: v },
|
||
})}
|
||
>
|
||
<SelectTrigger className="h-8 w-24 rounded-xl">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{[1, 2, 3, 4].map((l) => (
|
||
<SelectItem key={l} value={String(l)}>{l} — {t(`mastery.${l}`)}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</TableCell>
|
||
<TableCell className="hidden sm:table-cell">
|
||
<Input
|
||
className="h-8 rounded-xl border-emerald-200/50 dark:border-emerald-900/30"
|
||
value={gradingScores[s.id]?.note ?? ''}
|
||
onChange={(e) => setGradingScores({
|
||
...gradingScores,
|
||
[s.id]: { ...gradingScores[s.id], note: e.target.value },
|
||
})}
|
||
/>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{/* Score Distribution Histogram */}
|
||
{gradingAssessment && gradingResults.length > 0 && (
|
||
<ScoreDistributionHistogram assessment={gradingAssessment} results={gradingResults} />
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setGradingOpen(false)} className="rounded-xl">{t('action.cancel')}</Button>
|
||
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md shadow-emerald-300/20" onClick={handleSaveScores} disabled={savingScores}>
|
||
{savingScores ? t('empty.loading') : t('action.save')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Scratch Pad Sheet */}
|
||
<Sheet open={scratchPadOpen} onOpenChange={setScratchPadOpen}>
|
||
<SheetContent
|
||
side="right"
|
||
className="p-0 gap-0 w-auto sm:max-w-none flex flex-col"
|
||
style={{ width: scratchPadWidth }}
|
||
>
|
||
{/* Resize handle */}
|
||
<div
|
||
className="absolute top-0 left-0 bottom-0 w-2 cursor-col-resize hover:bg-emerald-200/40 dark:hover:bg-emerald-700/40 active:bg-emerald-300/60 dark:active:bg-emerald-600/60 transition-colors z-10 flex items-center justify-center"
|
||
onMouseDown={startResize}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label={t('assessments.scratch_pad_resize')}
|
||
tabIndex={0}
|
||
>
|
||
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||
</div>
|
||
<SheetHeader className="px-6 pt-4 pb-2 border-b border-emerald-200/30 dark:border-emerald-900/20">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||
<PenTool className="h-4 w-4" />
|
||
</div>
|
||
<SheetTitle className="text-base font-semibold">
|
||
{t('assessments.scratch_pad_title', { title: gradingAssessment?.title ?? '' })}
|
||
</SheetTitle>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300"
|
||
onClick={() => {
|
||
if (scratchPadWidth <= 480) setScratchPadWidth(640);
|
||
else if (scratchPadWidth <= 640) setScratchPadWidth(860);
|
||
else setScratchPadWidth(480);
|
||
}}
|
||
title={t('assessments.scratch_pad_resize')}
|
||
>
|
||
{scratchPadWidth <= 480 ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<SheetDescription className="text-xs text-muted-foreground mt-1">
|
||
{t('assessments.scratch_pad_description')}
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
<div className="flex-1 overflow-hidden">
|
||
<DrawingCanvas
|
||
backgroundType="lined"
|
||
initialDrawingData={scratchPadDrawingData ?? undefined}
|
||
onSave={handleSaveScratchPad}
|
||
title={`Scratch Pad: ${gradingAssessment?.title ?? ''}`}
|
||
/>
|
||
</div>
|
||
{scratchPadSaving && (
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20">
|
||
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 text-sm shadow-lg">
|
||
<div className="h-4 w-4 border-2 border-emerald-600 dark:border-emerald-400 border-t-transparent rounded-full animate-spin" />
|
||
{t('assessments.scratch_pad_saving')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
);
|
||
}
|