Features: - 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings - 34+ API endpoints - 30+ Prisma models - 700+ i18n keys (German + English) - Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/) - Batch deletion, drag-and-drop seating, custom color palette - Demo accounts (admin, teacher, student, parent) - Dark mode, responsive design, print styles - CSV/PDF export, notification system, onboarding tour - Zero emojis — all replaced with Lucide icons - MIT License
1403 lines
73 KiB
TypeScript
1403 lines
73 KiB
TypeScript
'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<string, string> = {
|
|
ELEMENTARY: 'border-l-emerald-500',
|
|
MIDDLE: 'border-l-teal-500',
|
|
GYMNASIUM: 'border-l-violet-500',
|
|
OTHER: 'border-l-amber-500',
|
|
};
|
|
|
|
const schoolTypeAccentBg: Record<string, string> = {
|
|
ELEMENTARY: 'bg-emerald-500',
|
|
MIDDLE: 'bg-teal-500',
|
|
GYMNASIUM: 'bg-violet-500',
|
|
OTHER: 'bg-amber-500',
|
|
};
|
|
|
|
const schoolTypeBadgeBg: Record<string, string> = {
|
|
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<string, string> = {
|
|
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<string, React.ReactNode> = {
|
|
ELEMENTARY: <School className="w-4 h-4" />,
|
|
MIDDLE: <GraduationCap className="w-4 h-4" />,
|
|
GYMNASIUM: <Library className="w-4 h-4" />,
|
|
OTHER: <Backpack className="w-4 h-4" />,
|
|
};
|
|
|
|
const schoolTypeText: Record<string, string> = {
|
|
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 <Sprout className={`${cls} text-red-500`} />;
|
|
if (level <= 2.5) return <Leaf className={`${cls} text-amber-500`} />;
|
|
if (level <= 3.5) return <TreePine className={`${cls} text-emerald-500`} />;
|
|
return <Trees className={`${cls} text-teal-500`} />;
|
|
};
|
|
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<ClassGroup[]>([]);
|
|
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
|
|
const [students, setStudents] = useState<Student[]>([]);
|
|
const [assignments, setAssignments] = useState<ClassCompetencyAssignment[]>([]);
|
|
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<Student[]>([]);
|
|
const [enrollStudentId, setEnrollStudentId] = useState('');
|
|
|
|
// Assign template dialog
|
|
const [assignOpen, setAssignOpen] = useState(false);
|
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
const [templates, setTemplates] = useState<CompetencyTemplate[]>([]);
|
|
const [assignSubjectId, setAssignSubjectId] = useState('');
|
|
const [assignTemplateId, setAssignTemplateId] = useState('');
|
|
|
|
// CSV import state
|
|
const [csvOpen, setCsvOpen] = useState(false);
|
|
const [csvFileName, setCsvFileName] = useState('');
|
|
const [csvRows, setCsvRows] = useState<Array<{
|
|
firstName: string;
|
|
lastName: string;
|
|
dateOfBirth?: string;
|
|
externalId?: string;
|
|
_rowNum: number;
|
|
_valid: boolean;
|
|
_error?: string;
|
|
}>>([]);
|
|
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<LearningProgressEntry[]>([]);
|
|
const [recentActivityLoading, setRecentActivityLoading] = useState(false);
|
|
|
|
// Seating order mode
|
|
const [seatingMode, setSeatingMode] = useState(false);
|
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
|
const [dragIdx, setDragIdx] = useState<number | null>(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 (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<Skeleton className="h-96 rounded-xl" />
|
|
<Skeleton className="h-96 lg:col-span-2 rounded-xl" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{/* Class list */}
|
|
<Card className="border-0 shadow-sm rounded-xl 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-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-300/20">
|
|
<School className="h-4 w-4" />
|
|
</div>
|
|
{t('classes.title')}
|
|
<div className="h-0.5 w-20 rounded-full bg-gradient-to-r from-emerald-400 to-transparent ml-1" />
|
|
</CardTitle>
|
|
<div className="relative mt-2">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('action.search')}
|
|
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 rounded-xl"
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="max-h-[70vh] overflow-y-auto scrollbar-education">
|
|
{filteredClasses.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="relative flex items-center justify-center w-24 h-24 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-6 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/20 ring-4 ring-emerald-50 dark:ring-emerald-900/30">
|
|
<School className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
|
|
<div className="absolute -bottom-1 -right-1 flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md">
|
|
<Plus className="h-4 w-4" />
|
|
</div>
|
|
</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('classes.no_classes')}</p>
|
|
<div className="mt-4 flex justify-center">
|
|
<div className="h-1 w-16 rounded-full bg-gradient-to-r from-emerald-300 to-teal-300 dark:from-emerald-600 dark:to-teal-600" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredClasses.map((cls) => (
|
|
<motion.button
|
|
key={cls.id}
|
|
whileHover={{ scale: 1.02, y: -3 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={() => 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 */}
|
|
<div className={`h-1.5 rounded-t-xl ${schoolTypeAccentBg[cls.schoolType] ?? 'bg-emerald-500'} opacity-60`} />
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<p className="font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
|
|
<Badge className={`${schoolTypeBadgeBg[cls.schoolType] ?? schoolTypeBadgeBg.OTHER} text-[10px] font-semibold rounded-md px-1.5 py-0.5 flex items-center gap-1`}>
|
|
{schoolTypeIcon[cls.schoolType] ?? <Backpack className="w-3 h-3" />}
|
|
{t(`school_type.${cls.schoolType.toLowerCase()}`)}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{/* Small progress indicator */}
|
|
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
|
|
style={{ width: `${progressPercent(cls)}%` }}
|
|
/>
|
|
</div>
|
|
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium">
|
|
{cls.studentCount ?? 0} <Users className="h-3 w-3 ml-1" />
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Class details & student roster */}
|
|
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
|
{!selectedClass ? (
|
|
<div className="space-y-4">
|
|
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
|
<CardContent className="py-16 text-center">
|
|
<div className="relative flex items-center justify-center w-24 h-24 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-6 shadow-lg shadow-emerald-200/50 dark:shadow-emerald-900/20 ring-4 ring-emerald-50 dark:ring-emerald-900/30">
|
|
<Users className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
|
|
<div className="absolute -top-1 -right-1 flex items-center justify-center w-6 h-6 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md">
|
|
<ArrowRight className="h-3 w-3" />
|
|
</div>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{t('polish.empty_title_no_class')}</h3>
|
|
<p className="text-sm text-muted-foreground max-w-md mx-auto leading-relaxed">{t('polish.empty_subtitle_no_class')}</p>
|
|
<div className="mt-4 flex justify-center">
|
|
<div className="h-1 w-16 rounded-full bg-gradient-to-r from-emerald-300 to-teal-300 dark:from-emerald-600 dark:to-teal-600" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent Activity widget — fills the empty space with useful info */}
|
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-400 overflow-hidden">
|
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
|
<CardTitle className="text-base font-bold flex items-center gap-2">
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shadow-teal-300/20">
|
|
<BarChart3 className="h-4 w-4" />
|
|
</div>
|
|
{t('polish.recent_activity_title')}
|
|
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-teal-400 to-transparent" />
|
|
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
|
|
· {t('polish.recent_activity_subtitle')}
|
|
</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{recentActivityLoading ? (
|
|
<div className="space-y-2">
|
|
{[0, 1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-gray-50/60 dark:bg-gray-800/40">
|
|
<Skeleton className="h-9 w-9 rounded-full" />
|
|
<div className="flex-1 space-y-1.5">
|
|
<Skeleton className="h-3 w-1/3" />
|
|
<Skeleton className="h-2.5 w-2/3" />
|
|
</div>
|
|
<Skeleton className="h-6 w-16 rounded-full" />
|
|
</div>
|
|
))}
|
|
<p className="text-center text-xs text-gray-400 dark:text-gray-500 pt-1">{t('polish.recent_activity_loading')}</p>
|
|
</div>
|
|
) : recentActivity.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-teal-50 dark:bg-teal-900/20 mx-auto mb-3">
|
|
<BarChart3 className="h-7 w-7 text-teal-400 dark:text-teal-500" />
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{t('polish.recent_activity_empty')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{recentActivity.map((entry) => (
|
|
<motion.div
|
|
key={entry.id}
|
|
whileHover={{ scale: 1.005 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="flex items-center justify-between p-3 rounded-lg bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3"
|
|
style={{ borderLeftColor: entry.competency.category.color ?? '#10b981' }}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="shrink-0 flex items-center justify-center w-9 h-9 rounded-full bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40 text-emerald-700 dark:text-emerald-300 text-xs font-bold ring-1 ring-emerald-200/50 dark:ring-emerald-900/30">
|
|
{entry.student.firstName[0]}{entry.student.lastName[0]}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
|
{entry.student.firstName} {entry.student.lastName}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
{entry.competency.category.name} → {entry.competency.title}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-1">
|
|
<Badge className={`${masteryBadgeClass(entry.masteryLevelValue)} text-xs font-medium rounded-full`}>
|
|
{masteryIcon(entry.masteryLevelValue)} {entry.masteryLevelValue}
|
|
</Badge>
|
|
<p className="text-[11px] text-emerald-600/60 dark:text-emerald-400/40">
|
|
{relativeDate(entry.date)}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Class details */}
|
|
<Card className="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">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
|
{selectedClass.name}
|
|
<div className="h-0.5 w-16 rounded-full bg-gradient-to-r from-emerald-400 to-transparent" />
|
|
</CardTitle>
|
|
<p className="text-sm text-emerald-600/60 dark:text-emerald-400/40 mt-1">
|
|
{t('label.grade')} {selectedClass.gradeLevel} · {t(`school_type.${selectedClass.schoolType.toLowerCase()}`)} · {selectedClass.schoolYear?.label}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2 flex-col sm:flex-row">
|
|
<Button size="sm" variant="outline" className="border-emerald-300 dark:border-emerald-700 rounded-xl" onClick={() => {
|
|
downloadCsvExport({
|
|
type: 'students',
|
|
classGroupId: selectedClass.id,
|
|
schoolYearId: selectedClass.schoolYearId,
|
|
});
|
|
toast.success(t('csv.export_success'));
|
|
}}>
|
|
<Download className="h-4 w-4 mr-1" />
|
|
{t('action.export')}
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="border-emerald-300 dark:border-emerald-700 rounded-xl" onClick={openAssignDialog}>
|
|
<Grid3X3 className="h-4 w-4 mr-1" />
|
|
{t('classes.assign_template')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Teachers */}
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">{t('classes.teacher_list')}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(selectedClass.teacherList ?? selectedClass.teachers?.map((tt) => ({ ...tt.user, teacherRole: tt.role })))?.map((teacher) => (
|
|
<Badge key={teacher.id} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs font-medium rounded-xl">
|
|
{teacher.firstName} {teacher.lastName}
|
|
<span className="ml-1 text-emerald-500/70 dark:text-emerald-400/50">
|
|
({teacher.teacherRole === 'HOMEROOM_TEACHER' ? t('classes.homeroom_teacher') : t('classes.subject_teacher')})
|
|
</span>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Assignments */}
|
|
{assignments.length > 0 && (
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">{t('competencies.assignments')}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{assignments.map((a) => (
|
|
<Badge key={a.id} className="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 text-xs font-medium rounded-xl">
|
|
{a.subject.name} → {a.competencyTemplate.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Class overview banner */}
|
|
<div className="relative overflow-hidden rounded-xl border border-emerald-200/40 dark:border-emerald-900/30 bg-mesh">
|
|
<div className="relative grid grid-cols-2 sm:grid-cols-4 gap-3 p-4">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
|
<Users className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-emerald-600/70 dark:text-emerald-400/60 font-semibold">{t('polish.total_students')}</p>
|
|
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{classStats.total}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
|
|
<PenLine className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-amber-600/70 dark:text-amber-400/60 font-semibold">{t('polish.total_entries')}</p>
|
|
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{classStats.totalEntries}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
|
|
<BarChart3 className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/60 font-semibold">{t('polish.avg_mastery')}</p>
|
|
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">Ø {classStats.avgEntries.toFixed(1)}</p>
|
|
</div>
|
|
</div>
|
|
<div className={`flex items-center gap-2.5 rounded-lg px-3 py-2 border ${healthMeta[classStats.health].bg}`}>
|
|
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-white/70 dark:bg-gray-800/60 ${healthMeta[classStats.health].text} shrink-0`}>
|
|
<HeartPulse className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] uppercase tracking-wide font-semibold opacity-80 whitespace-normal break-words leading-tight">{t('polish.class_health')}</p>
|
|
<p className={`text-xs font-bold flex items-center gap-1.5 whitespace-normal break-words leading-tight ${healthMeta[classStats.health].text}`}>
|
|
<span className={`inline-block w-2 h-2 rounded-full ${healthMeta[classStats.health].dot} animate-pulse shrink-0`} />
|
|
<span className="break-words leading-tight">{healthMeta[classStats.health].label}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Student roster */}
|
|
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
|
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<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-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shadow-teal-300/20">
|
|
<Users className="h-4 w-4" />
|
|
</div>
|
|
{t('classes.student_roster')}
|
|
<Badge className="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 text-xs font-medium">{students.length}</Badge>
|
|
<div className="h-0.5 w-12 rounded-full bg-gradient-to-r from-teal-400 to-transparent" />
|
|
</CardTitle>
|
|
<div className="flex gap-2 flex-wrap items-center">
|
|
<Button
|
|
size="sm"
|
|
variant={seatingMode ? 'default' : 'outline'}
|
|
className={`rounded-xl ${seatingMode ? 'bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md shadow-amber-300/20' : 'border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300'}`}
|
|
onClick={() => setSeatingMode(!seatingMode)}
|
|
>
|
|
<Armchair className="h-4 w-4 mr-1" />
|
|
{t('classes.seating_order')}
|
|
</Button>
|
|
<div className="relative">
|
|
<Filter className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-teal-400" />
|
|
<Input
|
|
value={studentSearch}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<Button size="sm" variant="outline" className="border-teal-300 dark:border-teal-700 rounded-xl" onClick={openEnrollDialog}>
|
|
<UserPlus className="h-4 w-4 mr-1" />
|
|
{t('classes.enroll_student')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300 rounded-xl"
|
|
onClick={() => {
|
|
setCsvFileName('');
|
|
setCsvRows([]);
|
|
setCsvResult(null);
|
|
setCsvOpen(true);
|
|
}}
|
|
>
|
|
<Upload className="h-4 w-4 mr-1" />
|
|
{t('classes.import_csv')}
|
|
</Button>
|
|
<Button size="sm" 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={() => setAddStudentOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t('classes.add_student')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{studentSearch.trim() && (
|
|
<p className="text-xs text-teal-600/70 dark:text-teal-400/60 mt-2">
|
|
{t('polish.students_found', { count: filteredStudents.length })}
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{studentsLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 rounded-xl" />
|
|
))}
|
|
</div>
|
|
) : students.length === 0 ? (
|
|
<div className="text-center py-10">
|
|
<div className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-lg shadow-teal-200/50 dark:shadow-teal-900/20 ring-4 ring-teal-50 dark:ring-teal-900/30">
|
|
<UserPlus className="h-10 w-10 text-teal-500 dark:text-teal-400" />
|
|
<div className="absolute -bottom-1 -right-1 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-md">
|
|
<Plus className="h-3 w-3" />
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{t('classes.no_students')}</p>
|
|
<div className="flex justify-center">
|
|
<div className="h-1 w-16 rounded-full bg-gradient-to-r from-teal-300 to-emerald-300 dark:from-teal-600 dark:to-emerald-600" />
|
|
</div>
|
|
</div>
|
|
) : filteredStudents.length === 0 ? (
|
|
<div className="text-center py-10">
|
|
<div className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-lg shadow-teal-200/50 dark:shadow-teal-900/20 ring-4 ring-teal-50 dark:ring-teal-900/30">
|
|
<Search className="h-10 w-10 text-teal-500 dark:text-teal-400" />
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{t('polish.no_results')}</p>
|
|
<div className="flex justify-center">
|
|
<div className="h-1 w-16 rounded-full bg-gradient-to-r from-teal-300 to-emerald-300 dark:from-teal-600 dark:to-emerald-600" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="max-h-96 overflow-y-auto scrollbar-education sticky-header">
|
|
{seatingMode && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="mb-3 px-4 py-3 rounded-xl bg-gradient-to-r from-amber-50/80 to-amber-50/40 dark:from-amber-900/20 dark:to-amber-900/10 border border-amber-200/50 dark:border-amber-900/30 text-xs text-amber-700 dark:text-amber-300 flex items-center gap-3 shadow-sm"
|
|
>
|
|
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
|
|
<Armchair className="h-3 w-3" />
|
|
</div>
|
|
<span className="font-medium">{t('classes.drag_hint')}</span>
|
|
{reorderSaving && (
|
|
<span className="inline-flex items-center gap-1 text-amber-500">
|
|
<div className="animate-spin h-3 w-3 border-2 border-amber-500 border-t-transparent rounded-full" />
|
|
{t('empty.loading')}
|
|
</span>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b border-emerald-200/30 dark:border-emerald-900/20">
|
|
{seatingMode && (
|
|
<TableHead className="w-10 text-xs font-semibold uppercase tracking-wider text-amber-600/60 dark:text-amber-400/40">#</TableHead>
|
|
)}
|
|
{seatingMode && (
|
|
<TableHead className="w-10"></TableHead>
|
|
)}
|
|
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.last_name')}</TableHead>
|
|
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('label.first_name')}</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.date_of_birth')}</TableHead>
|
|
{!seatingMode && (
|
|
<TableHead className="text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('polish.mastery_indicator')}</TableHead>
|
|
)}
|
|
{!seatingMode && (
|
|
<TableHead className="hidden md:table-cell text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('polish.last_entry')}</TableHead>
|
|
)}
|
|
<TableHead className="text-right text-xs font-semibold uppercase tracking-wider text-emerald-600/60 dark:text-emerald-400/40">{t('classes.entry_count')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{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 (
|
|
<TableRow
|
|
key={s.id}
|
|
draggable={seatingMode}
|
|
onDragStart={(e) => {
|
|
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 && (
|
|
<TableCell className="text-center">
|
|
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-amber-400 to-amber-500 text-white text-xs font-bold shadow-sm shadow-amber-300/20">
|
|
{idx + 1}
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
{seatingMode && (
|
|
<TableCell className="text-center">
|
|
<GripVertical className="h-4 w-4 text-amber-400 dark:text-amber-500 mx-auto animate-pulse" />
|
|
</TableCell>
|
|
)}
|
|
<TableCell className="font-semibold">{s.lastName}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br ${avatarGradientFor(s)} text-xs font-bold shrink-0 ring-2 shadow-sm`}>
|
|
{s.firstName[0]}{s.lastName[0]}
|
|
</div>
|
|
{s.firstName}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden sm:table-cell text-gray-500">
|
|
{s.dateOfBirth ? new Date(s.dateOfBirth).toLocaleDateString() : '—'}
|
|
</TableCell>
|
|
{!seatingMode && (
|
|
<TableCell>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className={`inline-block w-2.5 h-2.5 rounded-full ${masteryDot} ring-1 ring-white dark:ring-gray-900 shadow-sm ${pseudoMastery > 0 ? 'animate-pulse' : ''}`}
|
|
title={`${t('polish.mastery_indicator')}: ${masteryLabel}`}
|
|
/>
|
|
<span className="text-[10px] text-gray-500 dark:text-gray-400 hidden sm:inline">{masteryLabel}</span>
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
{!seatingMode && (
|
|
<TableCell className="hidden md:table-cell">
|
|
<Badge variant="outline" className={`inline-flex items-center gap-1.5 text-xs font-medium rounded-lg ${days === null ? 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500' : 'border-emerald-200/60 dark:border-emerald-900/30 text-gray-600 dark:text-gray-300 bg-emerald-50/30 dark:bg-emerald-900/10'}`}>
|
|
<CalendarClock className={`h-3 w-3 ${days === null ? 'text-gray-300 dark:text-gray-600' : 'text-emerald-500/70'}`} />
|
|
{formatRelativeDays(days, locale)}
|
|
</Badge>
|
|
</TableCell>
|
|
)}
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center gap-2 justify-end">
|
|
{/* Mini progress bar */}
|
|
<div className="w-12 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 animate-progress-fill"
|
|
style={{ width: `${Math.min((entryCount / 10) * 100, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<Badge className={`${entryCountBadge(entryCount)} text-xs font-semibold rounded-xl`}>
|
|
{entryCount}
|
|
</Badge>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add student dialog */}
|
|
<Dialog open={addStudentOpen} onOpenChange={setAddStudentOpen}>
|
|
<DialogContent className="rounded-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg font-bold">{t('classes.add_student_title')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-5 pt-2">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('label.first_name')}</Label>
|
|
<Input value={newFirstName} onChange={(e) => setNewFirstName(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('label.last_name')}</Label>
|
|
<Input value={newLastName} onChange={(e) => setNewLastName(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('label.date_of_birth')}</Label>
|
|
<Input type="date" value={newDob} onChange={(e) => setNewDob(e.target.value)} className="rounded-xl border-emerald-200/50 dark:border-emerald-900/30" />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddStudentOpen(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" onClick={handleAddStudent} disabled={!newFirstName || !newLastName}>
|
|
{t('action.create')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Enroll student dialog */}
|
|
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
|
<DialogContent className="rounded-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg font-bold">{t('classes.enroll_student_title')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-5 pt-2">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('action.select')}</Label>
|
|
<Select value={enrollStudentId} onValueChange={setEnrollStudentId}>
|
|
<SelectTrigger className="rounded-xl">
|
|
<SelectValue placeholder={t('classes.select_class')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{allStudents.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>
|
|
{s.lastName}, {s.firstName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEnrollOpen(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" onClick={handleEnroll} disabled={!enrollStudentId}>
|
|
{t('classes.enroll_student')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Assign template dialog */}
|
|
<Dialog open={assignOpen} onOpenChange={setAssignOpen}>
|
|
<DialogContent className="rounded-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg font-bold">{t('classes.assign_template')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-5 pt-2">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('label.subject')}</Label>
|
|
<Select value={assignSubjectId} onValueChange={setAssignSubjectId}>
|
|
<SelectTrigger className="rounded-xl">
|
|
<SelectValue placeholder={t('progress.select_subject')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{subjects.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('competencies.title')}</Label>
|
|
<Select value={assignTemplateId} onValueChange={setAssignTemplateId}>
|
|
<SelectTrigger className="rounded-xl">
|
|
<SelectValue placeholder={t('action.select')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{templates.map((tt) => (
|
|
<SelectItem key={tt.id} value={tt.id}>{tt.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAssignOpen(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" onClick={handleAssignTemplate} disabled={!assignSubjectId || !assignTemplateId}>
|
|
{t('action.assign')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* CSV import dialog */}
|
|
<Dialog open={csvOpen} onOpenChange={(open) => { if (!open) resetCsvDialog(); }}>
|
|
<DialogContent className="sm:max-w-2xl rounded-xl" aria-describedby={undefined}>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-lg font-bold flex items-center gap-2">
|
|
<FileUp className="h-5 w-5 text-violet-500" />
|
|
{t('classes.import_csv_title')}
|
|
</DialogTitle>
|
|
<DialogDescription>{t('classes.import_csv_subtitle')}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{!csvResult && (
|
|
<>
|
|
{/* File upload area */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium flex items-center gap-2">
|
|
<Upload className="h-4 w-4 text-violet-500" />
|
|
{t('classes.import_csv_select_file')}
|
|
</Label>
|
|
<label
|
|
htmlFor="csv-file-input"
|
|
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-violet-300 dark:border-violet-700 rounded-xl cursor-pointer bg-violet-50/50 dark:bg-violet-900/10 hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors"
|
|
>
|
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
|
<FileText className="w-8 h-8 text-violet-400 dark:text-violet-500 mb-2" />
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{csvFileName
|
|
? t('classes.import_csv_file_selected', { name: csvFileName, rows: csvRows.length })
|
|
: t('classes.import_csv_drag')}
|
|
</p>
|
|
</div>
|
|
<input
|
|
id="csv-file-input"
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFileSelected(file);
|
|
// Reset input value so the same file can be re-selected
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</label>
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
onClick={downloadTemplate}
|
|
className="text-violet-600 dark:text-violet-300 p-0 h-auto"
|
|
>
|
|
<Download className="h-3 w-3 mr-1" />
|
|
{t('classes.import_csv_template')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Preview table */}
|
|
{csvRows.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">{t('classes.import_csv_preview')} ({csvRows.length})</Label>
|
|
<div className="max-h-64 overflow-y-auto scrollbar-education border border-violet-200/50 dark:border-violet-900/30 rounded-xl">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-b border-violet-200/30 dark:border-violet-900/20 bg-violet-50/50 dark:bg-violet-900/10">
|
|
<TableHead className="text-xs uppercase text-violet-600/70 dark:text-violet-400/50 w-12">#</TableHead>
|
|
<TableHead className="text-xs uppercase text-violet-600/70 dark:text-violet-400/50">{t('classes.import_csv_first_name')}</TableHead>
|
|
<TableHead className="text-xs uppercase text-violet-600/70 dark:text-violet-400/50">{t('classes.import_csv_last_name')}</TableHead>
|
|
<TableHead className="text-xs uppercase text-violet-600/70 dark:text-violet-400/50">{t('classes.import_csv_dob')}</TableHead>
|
|
<TableHead className="text-xs uppercase text-violet-600/70 dark:text-violet-400/50">{t('classes.import_csv_external_id')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{csvRows.map((r) => (
|
|
<TableRow
|
|
key={r._rowNum}
|
|
className={r._valid ? '' : 'bg-red-50/50 dark:bg-red-900/10'}
|
|
>
|
|
<TableCell className="text-xs text-gray-500 dark:text-gray-400 font-mono">{r._rowNum}</TableCell>
|
|
<TableCell className="text-sm">{r.firstName || <span className="text-red-500">—</span>}</TableCell>
|
|
<TableCell className="text-sm">{r.lastName || <span className="text-red-500">—</span>}</TableCell>
|
|
<TableCell className="text-sm text-gray-500">{r.dateOfBirth || '—'}</TableCell>
|
|
<TableCell className="text-sm text-gray-500">{r.externalId || '—'}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{csvRows.some((r) => !r._valid) && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
{csvRows.filter((r) => !r._valid).length} {t('classes.import_csv_errors', { errors: csvRows.filter((r) => !r._valid).length })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Import results */}
|
|
{csvResult && (
|
|
<div className="space-y-3">
|
|
<div className="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-900/10 border border-emerald-200/50 dark:border-emerald-900/30 flex items-center gap-3">
|
|
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
|
|
<div>
|
|
<p className="font-semibold text-emerald-700 dark:text-emerald-300">
|
|
{t('classes.import_csv_success', { success: csvResult.created })}
|
|
</p>
|
|
{csvResult.enrolled > 0 && (
|
|
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50">
|
|
{csvResult.enrolled} {t('classes.enroll_student').toLowerCase()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{csvResult.errors.length > 0 && (
|
|
<div className="p-3 rounded-xl bg-red-50 dark:bg-red-900/10 border border-red-200/50 dark:border-red-900/30">
|
|
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2 flex items-center gap-1">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
{t('classes.import_csv_errors', { errors: csvResult.errors.length })}
|
|
</p>
|
|
<div className="max-h-32 overflow-y-auto scrollbar-education space-y-1">
|
|
{csvResult.errors.map((e, i) => (
|
|
<p key={i} className="text-xs text-red-600 dark:text-red-400">
|
|
{t('classes.import_csv_row', { n: e.row })}: {e.error}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
{csvResult ? (
|
|
<Button onClick={resetCsvDialog} className="bg-gradient-to-r from-emerald-500 to-teal-500 text-white rounded-xl">
|
|
{t('classes.import_csv_done')}
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button variant="outline" onClick={resetCsvDialog} className="rounded-xl">{t('action.cancel')}</Button>
|
|
<Button
|
|
onClick={handleBulkImport}
|
|
disabled={csvImporting || csvRows.filter((r) => r._valid).length === 0}
|
|
className="bg-gradient-to-r from-violet-500 to-emerald-500 text-white rounded-xl"
|
|
>
|
|
{csvImporting ? (
|
|
<>
|
|
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2" />
|
|
{t('classes.import_csv_processing')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
{t('classes.import_csv_import')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|