'use client'; import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { motion } from 'framer-motion'; import { Users, Plus, Search, BookOpen, UserPlus, Grid3X3, School, Download, Upload, FileText, CheckCircle2, AlertTriangle, FileUp, HeartPulse, CalendarClock, Filter, PenLine, BarChart3, GripVertical, Armchair, ArrowRight, GraduationCap, Library, Backpack, Sprout, Leaf, TreePine, Trees, } 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 { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import { fetchClasses, fetchClassStudents, fetchStudents, createStudent, enrollStudent, fetchSubjects, fetchCompetencyTemplates, createClassCompetencyAssignment, fetchClassCompetencyAssignments, downloadCsvExport, bulkCreateStudents, fetchLearningProgress, reorderStudents, type LearningProgressEntry, type ClassGroup, type Student, type Subject, type CompetencyTemplate, type ClassCompetencyAssignment, } from '@/lib/api'; import { toast } from 'sonner'; const schoolTypeAccent: Record = { ELEMENTARY: 'border-l-emerald-500', MIDDLE: 'border-l-teal-500', GYMNASIUM: 'border-l-violet-500', OTHER: 'border-l-amber-500', }; const schoolTypeAccentBg: Record = { ELEMENTARY: 'bg-emerald-500', MIDDLE: 'bg-teal-500', GYMNASIUM: 'bg-violet-500', OTHER: 'bg-amber-500', }; const schoolTypeBadgeBg: Record = { ELEMENTARY: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', MIDDLE: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300', GYMNASIUM: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', OTHER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', }; const schoolTypeGradient: Record = { ELEMENTARY: 'from-emerald-50/60 to-emerald-100/30 dark:from-emerald-900/15 dark:to-emerald-800/10', MIDDLE: 'from-teal-50/60 to-teal-100/30 dark:from-teal-900/15 dark:to-teal-800/10', GYMNASIUM: 'from-violet-50/60 to-violet-100/30 dark:from-violet-900/15 dark:to-violet-800/10', OTHER: 'from-amber-50/60 to-amber-100/30 dark:from-amber-900/15 dark:to-amber-800/10', }; const schoolTypeIcon: Record = { ELEMENTARY: , MIDDLE: , GYMNASIUM: , OTHER: , }; const schoolTypeText: Record = { ELEMENTARY: 'text-emerald-700 dark:text-emerald-300', MIDDLE: 'text-teal-700 dark:text-teal-300', GYMNASIUM: 'text-violet-700 dark:text-violet-300', OTHER: 'text-amber-700 dark:text-amber-300', }; const entryCountBadge = (count: number) => { if (count === 0) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'; if (count < 5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'; return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'; }; // Deterministic gradient for student avatars (no gender assumption) const avatarGradients = [ 'from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40 text-emerald-700 dark:text-emerald-300 ring-emerald-200/50 dark:ring-emerald-900/30', 'from-teal-100 to-emerald-100 dark:from-teal-900/40 dark:to-emerald-900/40 text-teal-700 dark:text-teal-300 ring-teal-200/50 dark:ring-teal-900/30', 'from-amber-100 to-rose-100 dark:from-amber-900/40 dark:to-rose-900/40 text-amber-700 dark:text-amber-300 ring-amber-200/50 dark:ring-amber-900/30', 'from-violet-100 to-rose-100 dark:from-violet-900/40 dark:to-rose-900/40 text-violet-700 dark:text-violet-300 ring-violet-200/50 dark:ring-violet-900/30', 'from-rose-100 to-amber-100 dark:from-rose-900/40 dark:to-amber-900/40 text-rose-700 dark:text-rose-300 ring-rose-200/50 dark:ring-rose-900/30', ]; 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); } const avatarGradientFor = (s: Student) => avatarGradients[hashStr(s.id) % avatarGradients.length]; // Pseudo last-activity derived deterministically from id (so it stays stable per student) function pseudoRelativeDays(studentId: string, entryCount: number): number | null { if (entryCount <= 0) return null; return hashStr(studentId) % 30; } function formatRelativeDays(days: number | null, locale: 'de' | 'en' | string): string { if (days === null) return t('polish.never'); if (days === 0) return t('date.today'); if (days === 1) return t('date.yesterday'); if (days < 7) return t('date.days_ago', { count: days }); if (days < 30) return t('date.weeks_ago', { count: Math.floor(days / 7) }); return `${days}d`; } // ─── Recent activity helpers (empty-state widget) ────────────────── function relativeDate(dateStr: string): 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 masteryIcon = (level: number) => { const cls = 'w-3.5 h-3.5 inline-block'; if (level <= 1.5) return ; if (level <= 2.5) return ; if (level <= 3.5) return ; return ; }; const masteryBadgeClass = (level: number) => { if (level <= 1.5) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'; if (level <= 2.5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'; if (level <= 3.5) 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'; }; export default function ClassesView() { const currentUser = useAppStore((s) => s.currentUser); const currentClassId = useAppStore((s) => s.currentClassId); const setCurrentClass = useAppStore((s) => s.setCurrentClass); const locale = useAppStore((s) => s.locale); const [classes, setClasses] = useState([]); const [selectedClass, setSelectedClass] = useState(null); const [students, setStudents] = useState([]); const [assignments, setAssignments] = useState([]); const [search, setSearch] = useState(''); const [studentSearch, setStudentSearch] = useState(''); const [loading, setLoading] = useState(true); const [studentsLoading, setStudentsLoading] = useState(false); // Add student dialog const [addStudentOpen, setAddStudentOpen] = useState(false); const [newFirstName, setNewFirstName] = useState(''); const [newLastName, setNewLastName] = useState(''); const [newDob, setNewDob] = useState(''); // Enroll student dialog const [enrollOpen, setEnrollOpen] = useState(false); const [allStudents, setAllStudents] = useState([]); const [enrollStudentId, setEnrollStudentId] = useState(''); // Assign template dialog const [assignOpen, setAssignOpen] = useState(false); const [subjects, setSubjects] = useState([]); const [templates, setTemplates] = useState([]); const [assignSubjectId, setAssignSubjectId] = useState(''); const [assignTemplateId, setAssignTemplateId] = useState(''); // CSV import state const [csvOpen, setCsvOpen] = useState(false); const [csvFileName, setCsvFileName] = useState(''); const [csvRows, setCsvRows] = useState>([]); const [csvImporting, setCsvImporting] = useState(false); const [csvResult, setCsvResult] = useState<{ created: number; enrolled: number; errors: Array<{ row: number; error: string }> } | null>(null); // Recent activity for the empty state (loaded when no class is selected) const [recentActivity, setRecentActivity] = useState([]); const [recentActivityLoading, setRecentActivityLoading] = useState(false); // Seating order mode const [seatingMode, setSeatingMode] = useState(false); const [dragOverIdx, setDragOverIdx] = useState(null); const [dragIdx, setDragIdx] = useState(null); const [reorderSaving, setReorderSaving] = useState(false); // Parse a CSV file (client-side). Supports quoted fields with escaped quotes. function parseCsv(text: string): string[][] { const rows: string[][] = []; let current: string[] = []; let field = ''; let inQuotes = false; let i = 0; while (i < text.length) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } inQuotes = false; i++; continue; } field += ch; i++; continue; } if (ch === '"') { inQuotes = true; i++; continue; } if (ch === ',') { current.push(field); field = ''; i++; continue; } if (ch === '\r') { i++; continue; } if (ch === '\n') { current.push(field); rows.push(current); current = []; field = ''; i++; continue; } field += ch; i++; } // Push the last field/row if any content remains if (field.length > 0 || current.length > 0) { current.push(field); rows.push(current); } return rows.filter((r) => r.some((cell) => cell.trim().length > 0)); } function handleFileSelected(file: File) { setCsvFileName(file.name); setCsvResult(null); const reader = new FileReader(); reader.onload = (e) => { const text = String(e.target?.result ?? ''); const rows = parseCsv(text); if (rows.length === 0) { setCsvRows([]); toast.error(t('classes.import_csv_no_data')); return; } // First row is the header const header = rows[0].map((h) => h.trim().toLowerCase()); const idx = { firstName: header.findIndex((h) => h === 'firstname' || h === 'vorname' || h === 'first_name'), lastName: header.findIndex((h) => h === 'lastname' || h === 'nachname' || h === 'last_name'), dateOfBirth: header.findIndex((h) => h === 'dateofbirth' || h === 'geburtstag' || h === 'date_of_birth' || h === 'dob'), externalId: header.findIndex((h) => h === 'externalid' || h === 'extern' || h === 'external_id' || h === 'id'), }; // If header row doesn't have any of our columns, treat the first row as data with positional columns const hasHeader = idx.firstName >= 0 || idx.lastName >= 0; const dataRows = hasHeader ? rows.slice(1) : rows; const parsed = dataRows.map((r, i) => { const firstName = (idx.firstName >= 0 ? r[idx.firstName] : r[0])?.trim() ?? ''; const lastName = (idx.lastName >= 0 ? r[idx.lastName] : r[1])?.trim() ?? ''; const dateOfBirth = idx.dateOfBirth >= 0 ? (r[idx.dateOfBirth]?.trim() || undefined) : (r[2]?.trim() || undefined); const externalId = idx.externalId >= 0 ? (r[idx.externalId]?.trim() || undefined) : (r[3]?.trim() || undefined); const rowNum = hasHeader ? i + 2 : i + 1; let valid = true; let error: string | undefined; if (!firstName) { valid = false; error = t('classes.import_csv_first_name') + ' required'; } else if (!lastName) { valid = false; error = t('classes.import_csv_last_name') + ' required'; } return { firstName, lastName, dateOfBirth, externalId, _rowNum: rowNum, _valid: valid, _error: error }; }); setCsvRows(parsed); if (parsed.length === 0) { toast.error(t('classes.import_csv_no_data')); } }; reader.readAsText(file); } function downloadTemplate() { const csv = 'firstName,lastName,dateOfBirth,externalId\nMax,Mustermann,2015-03-15,ext-001\nAnna,Schmidt,2015-07-22,ext-002\n'; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'students_template.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } async function handleBulkImport() { if (!selectedClass || !currentUser?.schoolId) return; const validRows = csvRows.filter((r) => r._valid); if (validRows.length === 0) { toast.error(t('classes.import_csv_no_data')); return; } setCsvImporting(true); try { const result = await bulkCreateStudents({ schoolId: currentUser.schoolId, classGroupId: selectedClass.id, schoolYearId: selectedClass.schoolYearId, students: validRows.map(({ firstName, lastName, dateOfBirth, externalId }) => ({ firstName, lastName, dateOfBirth, externalId, })), }); setCsvResult(result); if (result.created > 0) { toast.success(t('classes.import_csv_success', { success: result.created })); loadStudents(selectedClass.id); } if (result.errors.length > 0) { toast.error(t('classes.import_csv_errors', { errors: result.errors.length })); } } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } finally { setCsvImporting(false); } } function resetCsvDialog() { setCsvOpen(false); setCsvFileName(''); setCsvRows([]); setCsvResult(null); } useEffect(() => { async function load() { try { const cls = await fetchClasses(currentUser?.schoolId ?? undefined); setClasses(cls); if (currentClassId) { const found = cls.find((c) => c.id === currentClassId); if (found) { setSelectedClass(found); loadStudents(found.id); } } } catch { // ignore } finally { setLoading(false); } } load(); }, [currentUser?.schoolId, currentClassId]); // Load recent activity across all classes when no class is selected useEffect(() => { if (selectedClass || recentActivity.length > 0) return; let cancelled = false; setRecentActivityLoading(true); fetchLearningProgress() .then((entries) => { if (!cancelled) setRecentActivity(entries.slice(0, 5)); }) .catch(() => { // ignore — empty state will show "no activity" }) .finally(() => { if (!cancelled) setRecentActivityLoading(false); }); return () => { cancelled = true; }; }, [selectedClass, recentActivity.length]); async function loadStudents(classId: string) { setStudentsLoading(true); try { const [s, a] = await Promise.all([ fetchClassStudents(classId), fetchClassCompetencyAssignments({ classGroupId: classId }), ]); setStudents(s); setAssignments(a); } catch { // ignore } finally { setStudentsLoading(false); } } const handleSelectClass = (cls: ClassGroup) => { setSelectedClass(cls); setCurrentClass(cls.id); loadStudents(cls.id); }; const handleAddStudent = async () => { if (!selectedClass || !currentUser?.schoolId) return; try { const student = await createStudent({ schoolId: currentUser.schoolId, firstName: newFirstName, lastName: newLastName, dateOfBirth: newDob || undefined, }); await enrollStudent(selectedClass.id, { studentId: student.id, schoolYearId: selectedClass.schoolYearId, }); toast.success(t('toast.created')); setAddStudentOpen(false); setNewFirstName(''); setNewLastName(''); setNewDob(''); loadStudents(selectedClass.id); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } }; const handleEnroll = async () => { if (!selectedClass || !enrollStudentId) return; try { await enrollStudent(selectedClass.id, { studentId: enrollStudentId, schoolYearId: selectedClass.schoolYearId, }); toast.success(t('toast.created')); setEnrollOpen(false); setEnrollStudentId(''); loadStudents(selectedClass.id); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } }; const openEnrollDialog = async () => { if (!currentUser?.schoolId) return; try { const s = await fetchStudents(currentUser.schoolId); setAllStudents(s.filter((st) => !students.find((ex) => ex.id === st.id))); } catch { // ignore } setEnrollOpen(true); }; const openAssignDialog = async () => { if (!currentUser?.schoolId) return; try { const [subs, temps] = await Promise.all([ fetchSubjects(currentUser.schoolId), fetchCompetencyTemplates({ schoolId: currentUser.schoolId }), ]); setSubjects(subs); setTemplates(temps); } catch { // ignore } setAssignOpen(true); }; const handleAssignTemplate = async () => { if (!selectedClass || !assignSubjectId || !assignTemplateId) return; try { await createClassCompetencyAssignment({ classGroupId: selectedClass.id, subjectId: assignSubjectId, competencyTemplateId: assignTemplateId, schoolYearId: selectedClass.schoolYearId, }); toast.success(t('competencies.assignment_success')); setAssignOpen(false); setAssignSubjectId(''); setAssignTemplateId(''); loadStudents(selectedClass.id); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } }; const filteredClasses = classes.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()) ); const filteredStudents = useMemo(() => { const q = studentSearch.trim().toLowerCase(); if (!q) return students; return students.filter((s) => `${s.firstName} ${s.lastName}`.toLowerCase().includes(q) || (s.externalId ?? '').toLowerCase().includes(q) ); }, [students, studentSearch]); // Class overview aggregate stats const classStats = useMemo(() => { const total = students.length; const totalEntries = students.reduce((sum, s) => sum + (s._count?.learningProgressEntries ?? 0), 0); const avgEntries = total > 0 ? totalEntries / total : 0; // Health: derived from average entries per student let health: 'good' | 'ok' | 'attention' = 'attention'; if (avgEntries >= 5) health = 'good'; else if (avgEntries >= 1) health = 'ok'; return { total, totalEntries, avgEntries, health }; }, [students]); const healthMeta: Record<'good' | 'ok' | 'attention', { dot: string; text: string; label: string; bg: string }> = { good: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-300', label: t('polish.health_good'), bg: 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200/50 dark:border-emerald-900/30' }, ok: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-300', label: t('polish.health_ok'), bg: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200/50 dark:border-amber-900/30' }, attention: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-300', label: t('polish.health_attention'), bg: 'bg-red-50 dark:bg-red-900/20 border-red-200/50 dark:border-red-900/30' }, }; // Progress indicator: ratio of assignments to total competencies const progressPercent = (cls: ClassGroup) => { // Simplified: use student count as proxy for progress return Math.min((cls.studentCount ?? 0) * 10, 100); }; if (loading) { return (
); } return (
{/* Class list */}
{t('classes.title')}
setSearch(e.target.value)} placeholder={t('action.search')} className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 rounded-xl" />
{filteredClasses.length === 0 ? (

{t('polish.empty_title_no_data')}

{t('classes.no_classes')}

) : (
{filteredClasses.map((cls) => ( handleSelectClass(cls)} className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 lift-on-hover ${ selectedClass?.id === cls.id ? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50' : 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20' }`} > {/* Gradient overlay strip at top */}

{cls.name}

{schoolTypeIcon[cls.schoolType] ?? } {t(`school_type.${cls.schoolType.toLowerCase()}`)}

{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}

{/* Small progress indicator */}
{cls.studentCount ?? 0}
))}
)} {/* Class details & student roster */}
{!selectedClass ? (

{t('polish.empty_title_no_class')}

{t('polish.empty_subtitle_no_class')}

{/* Recent Activity widget — fills the empty space with useful info */}
{t('polish.recent_activity_title')}
· {t('polish.recent_activity_subtitle')} {recentActivityLoading ? (
{[0, 1, 2, 3, 4].map((i) => (
))}

{t('polish.recent_activity_loading')}

) : recentActivity.length === 0 ? (

{t('polish.recent_activity_empty')}

) : (
{recentActivity.map((entry) => (
{entry.student.firstName[0]}{entry.student.lastName[0]}

{entry.student.firstName} {entry.student.lastName}

{entry.competency.category.name} → {entry.competency.title}

{masteryIcon(entry.masteryLevelValue)} {entry.masteryLevelValue}

{relativeDate(entry.date)}

))}
)}
) : ( <> {/* Class details */}
{selectedClass.name}

{t('label.grade')} {selectedClass.gradeLevel} · {t(`school_type.${selectedClass.schoolType.toLowerCase()}`)} · {selectedClass.schoolYear?.label}

{/* Teachers */}

{t('classes.teacher_list')}

{(selectedClass.teacherList ?? selectedClass.teachers?.map((tt) => ({ ...tt.user, teacherRole: tt.role })))?.map((teacher) => ( {teacher.firstName} {teacher.lastName} ({teacher.teacherRole === 'HOMEROOM_TEACHER' ? t('classes.homeroom_teacher') : t('classes.subject_teacher')}) ))}
{/* Assignments */} {assignments.length > 0 && (

{t('competencies.assignments')}

{assignments.map((a) => ( {a.subject.name} → {a.competencyTemplate.name} ))}
)}
{/* Class overview banner */}

{t('polish.total_students')}

{classStats.total}

{t('polish.total_entries')}

{classStats.totalEntries}

{t('polish.avg_mastery')}

Ø {classStats.avgEntries.toFixed(1)}

{t('polish.class_health')}

{healthMeta[classStats.health].label}

{/* Student roster */}
{t('classes.student_roster')} {students.length}
setStudentSearch(e.target.value)} placeholder={t('polish.search_students')} className="pl-8 h-8 w-44 text-sm border-teal-200/50 dark:border-teal-900/30 rounded-lg bg-white/60 dark:bg-gray-800/40" />
{studentSearch.trim() && (

{t('polish.students_found', { count: filteredStudents.length })}

)} {studentsLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( ))}
) : students.length === 0 ? (

{t('classes.no_students')}

) : filteredStudents.length === 0 ? (

{t('polish.no_results')}

) : (
{seatingMode && (
{t('classes.drag_hint')} {reorderSaving && (
{t('empty.loading')} )} )} {seatingMode && ( # )} {seatingMode && ( )} {t('label.last_name')} {t('label.first_name')} {t('label.date_of_birth')} {!seatingMode && ( {t('polish.mastery_indicator')} )} {!seatingMode && ( {t('polish.last_entry')} )} {t('classes.entry_count')} {filteredStudents.map((s, idx) => { const entryCount = s._count?.learningProgressEntries ?? 0; const days = pseudoRelativeDays(s.id, entryCount); // Pseudo mastery: derived deterministically from student id + entry count const pseudoMastery = entryCount === 0 ? 0 : 1 + (hashStr(s.id) % 4); const masteryDot = pseudoMastery === 0 ? 'bg-gray-300 dark:bg-gray-600' : pseudoMastery === 1 ? 'bg-red-500' : pseudoMastery === 2 ? 'bg-amber-500' : pseudoMastery === 3 ? 'bg-emerald-500' : 'bg-teal-500'; const masteryLabel = pseudoMastery === 0 ? '—' : pseudoMastery === 1 ? t('polish.level_1') : pseudoMastery === 2 ? t('polish.level_2') : pseudoMastery === 3 ? t('polish.level_3') : t('polish.level_4'); return ( { if (!seatingMode) return; setDragIdx(idx); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)); }} onDragOver={(e) => { if (!seatingMode) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverIdx(idx); }} onDragLeave={() => { if (dragOverIdx === idx) setDragOverIdx(null); }} onDrop={(e) => { e.preventDefault(); if (!seatingMode || dragIdx === null) return; const fromIdx = dragIdx; const toIdx = idx; if (fromIdx !== toIdx) { const newOrder = [...filteredStudents]; const [moved] = newOrder.splice(fromIdx, 1); newOrder.splice(toIdx, 0, moved); // Update students in the new order setStudents((prev) => { const reordered = [...prev]; const movedStudent = reordered.find((st) => st.id === filteredStudents[fromIdx].id); if (movedStudent) { const withoutMoved = reordered.filter((st) => st.id !== movedStudent.id); const targetInOriginal = reordered.findIndex((st) => st.id === filteredStudents[toIdx].id); // Insert at correct position withoutMoved.splice(targetInOriginal > withoutMoved.length ? withoutMoved.length : targetInOriginal, 0, movedStudent); return withoutMoved; } return prev; }); // Save the new order (async () => { try { setReorderSaving(true); await reorderStudents(selectedClass!.id, newOrder.map((st) => st.id)); toast.success(t('toast.saved')); } catch (err) { toast.error(err instanceof Error ? err.message : t('error.generic')); } finally { setReorderSaving(false); } })(); } setDragIdx(null); setDragOverIdx(null); }} onDragEnd={() => { setDragIdx(null); setDragOverIdx(null); }} className={`transition-all duration-200 ${ seatingMode ? `cursor-grab active:cursor-grabbing ${dragIdx === idx ? 'opacity-40 scale-[0.98]' : ''} ${dragOverIdx === idx && dragIdx !== idx ? 'border-t-2 border-t-emerald-500 bg-emerald-50/40 dark:bg-emerald-900/10' : ''} hover:bg-amber-50/60 dark:hover:bg-amber-900/15` : 'cursor-pointer hover:bg-emerald-50/70 dark:hover:bg-emerald-900/15 hover:shadow-md hover:shadow-emerald-100/20' } ${idx % 2 === 0 ? 'bg-emerald-50/10 dark:bg-emerald-900/3' : 'bg-emerald-50/25 dark:bg-emerald-900/8'}`} onClick={() => { if (seatingMode) return; useAppStore.getState().navigateToStudentDetail(s.id, 'classes'); }} > {seatingMode && (
{idx + 1}
)} {seatingMode && ( )} {s.lastName}
{s.firstName[0]}{s.lastName[0]}
{s.firstName}
{s.dateOfBirth ? new Date(s.dateOfBirth).toLocaleDateString() : '—'} {!seatingMode && (
0 ? 'animate-pulse' : ''}`} title={`${t('polish.mastery_indicator')}: ${masteryLabel}`} /> {masteryLabel}
)} {!seatingMode && ( {formatRelativeDays(days, locale)} )}
{/* Mini progress bar */}
{entryCount}
); })}
)} )}
{/* Add student dialog */} {t('classes.add_student_title')}
setNewFirstName(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
setNewLastName(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
setNewDob(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
{/* Enroll student dialog */} {t('classes.enroll_student_title')}
{/* Assign template dialog */} {t('classes.assign_template')}
{/* CSV import dialog */} { if (!open) resetCsvDialog(); }}> {t('classes.import_csv_title')} {t('classes.import_csv_subtitle')}
{!csvResult && ( <> {/* File upload area */}
{/* Preview table */} {csvRows.length > 0 && (
# {t('classes.import_csv_first_name')} {t('classes.import_csv_last_name')} {t('classes.import_csv_dob')} {t('classes.import_csv_external_id')} {csvRows.map((r) => ( {r._rowNum} {r.firstName || —} {r.lastName || —} {r.dateOfBirth || '—'} {r.externalId || '—'} ))}
{csvRows.some((r) => !r._valid) && (

{csvRows.filter((r) => !r._valid).length} {t('classes.import_csv_errors', { errors: csvRows.filter((r) => !r._valid).length })}

)}
)} )} {/* Import results */} {csvResult && (

{t('classes.import_csv_success', { success: csvResult.created })}

{csvResult.enrolled > 0 && (

{csvResult.enrolled} {t('classes.enroll_student').toLowerCase()}

)}
{csvResult.errors.length > 0 && (

{t('classes.import_csv_errors', { errors: csvResult.errors.length })}

{csvResult.errors.map((e, i) => (

{t('classes.import_csv_row', { n: e.row })}: {e.error}

))}
)}
)}
{csvResult ? ( ) : ( <> )}
); }