edumanagerpro2/manager/components/ReportCard.tsx

869 lines
41 KiB
TypeScript

import React, { useState } from 'react';
import { SchoolData, Class, Student, Subject, Grade, Period } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import {
FileText,
Plus,
Trash2,
ChevronRight,
Save,
GraduationCap,
BookOpen,
User,
X,
Search,
CheckCircle2,
AlertCircle,
Calendar,
Calculator
} from 'lucide-react';
interface ReportCardProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [newSubjectName, setNewSubjectName] = useState('');
const [newPeriodName, setNewPeriodName] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [showConfigManager, setShowConfigManager] = useState(false);
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
const [studentSubmissions, setStudentSubmissions] = useState<Record<string, {acertos: number, erros: number}>>({}); // examId -> { acertos, erros }
const [classGrades, setClassGrades] = useState<Grade[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const periods = data.periods || [];
const grades = data.grades || [];
const [dbClasses, setDbClasses] = useState<Class[]>(data.classes || []);
const [dbCourses, setDbCourses] = useState<any[]>(data.courses || []);
const loadClassesAndCourses = async () => {
try {
const [clsRes, crsRes] = await Promise.all([
fetch('/api/turmas'),
fetch('/api/cursos')
]);
const clsData = await clsRes.json();
const crsData = await crsRes.json();
if (clsData.turmas) {
setDbClasses(clsData.turmas.map((t: any) => ({
id: t.id,
name: t.nome,
courseId: t.curso_id
})));
}
if (crsData.cursos) {
setDbCourses(crsData.cursos);
}
} catch(e) {
console.error('Erro ao buscar turmas/cursos:', e);
}
};
const loadSubjects = async () => {
try {
const res = await fetch('/api/disciplinas');
if (res.ok) {
const json = await res.json();
const mappedSubjects = (json.disciplinas || []).map((d: any) => ({
id: d.id,
name: d.nome
}));
setSubjects(mappedSubjects);
}
} catch (e) {
console.error('Erro ao buscar disciplinas:', e);
}
};
React.useEffect(() => {
loadSubjects();
loadClassesAndCourses();
}, []);
// Buscar todas as notas da turma para mostrar médias na lista
React.useEffect(() => {
if (selectedClass) {
const fetchClassGrades = async () => {
try {
const studentIds = data.students.filter(s => s.classId === selectedClass.id).map(s => s.id);
if (studentIds.length === 0) return;
const allGrades: Grade[] = [];
for (const id of studentIds) {
const res = await fetch(`/api/notas/${id}?t=${Date.now()}`);
if (res.ok) {
const json = await res.json();
(json.notas || []).forEach((n: any) => {
allGrades.push({
id: n.id,
studentId: n.aluno_id,
subjectId: n.disciplina_id,
period: n.periodo_id,
examId: n.prova_id,
value: Number(n.valor)
});
});
}
}
setClassGrades(allGrades);
} catch (e) {
console.error('Erro ao buscar notas da turma:', e);
}
};
fetchClassGrades();
} else {
setClassGrades([]);
}
}, [selectedClass, data.students]);
// Helper para normalizar URLs de fotos (vacina contra cache antigo)
const normalizePhotoUrl = (url?: string) => {
if (!url || typeof url !== 'string') return '';
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
if (url.startsWith('/storage/')) return url;
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch (e) { }
return url;
};
const handleAddSubject = async () => {
if (!newSubjectName.trim()) {
showAlert('Atenção', '⚠️ Por favor, informe o nome da disciplina.', 'warning');
return;
}
try {
const payload = {
id: crypto.randomUUID(),
nome: newSubjectName.trim()
};
const res = await fetch('/api/disciplinas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
await loadSubjects();
setNewSubjectName('');
} else {
showAlert('Erro', 'Falha ao salvar disciplina no banco.', 'error');
}
} catch (e) {
showAlert('Erro', 'Ocorreu um erro de conexão.', 'error');
}
};
const handleAddPeriod = () => {
if (!newPeriodName.trim()) {
showAlert('Atenção', '⚠️ Por favor, informe o nome do período.', 'warning');
return;
}
const newPeriod: Period = {
id: crypto.randomUUID(),
name: newPeriodName.trim()
};
const updatedPeriods = [...periods, newPeriod];
updateData({ periods: updatedPeriods });
dbService.saveData({ ...data, periods: updatedPeriods });
setNewPeriodName('');
};
const handleDeleteSubject = (id: string) => {
showConfirm(
'Excluir Disciplina',
'⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.',
async () => {
try {
const res = await fetch(`/api/disciplinas/${id}`, { method: 'DELETE' });
if (res.ok) {
await loadSubjects();
// Notas dependem da disciplina, mas a exclusão das notas sql pode ser em cascade no postgres
// Se ainda usar JSON para notas manuais:
const updatedGrades = grades.filter(g => g.subjectId !== id);
updateData({ grades: updatedGrades });
dbService.saveData({ ...data, grades: updatedGrades });
} else {
showAlert('Erro', 'Falha ao deletar disciplina.', 'error');
}
} catch(e) {
showAlert('Erro', 'Ocorreu um erro ao comunicar com a API.', 'error');
}
}
);
};
const handleDeletePeriod = (id: string) => {
showConfirm(
'Excluir Período',
'⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.',
() => {
const updatedPeriods = periods.filter(p => p.id !== id);
const updatedGrades = grades.filter(g => g.period !== id);
updateData({ periods: updatedPeriods, grades: updatedGrades });
dbService.saveData({ ...data, periods: updatedPeriods, grades: updatedGrades });
}
);
};
const handleOpenStudentGrades = async (student: Student) => {
setSelectedStudent(student);
const initialGrades: Record<string, Record<string, any>> = {};
// Buscar notas do Postgres (com cache busting)
let dbNotas: any[] = [];
try {
const resNotas = await fetch(`/api/notas/${student.id}?t=${new Date().getTime()}`);
if (resNotas.ok) {
const json = await resNotas.json();
dbNotas = json.notas || [];
}
} catch(e) {
console.error('Error fetching notas:', e);
}
let subsMap: Record<string, {acertos: number, erros: number}> = {};
try {
const res = await fetch(`/api/student-submissions/${student.id}?t=${new Date().getTime()}`);
if (res.ok) {
const { submissions } = await res.json();
(submissions || []).forEach((s: any) => {
// Normalização agressiva para garantir o vínculo
const pId = String(s.prova_id || '').trim();
if (pId) {
subsMap[pId] = { acertos: s.acertos, erros: s.erros };
}
});
setStudentSubmissions(subsMap);
}
} catch(e) {
console.error('Error fetching submissions:', e);
}
subjects.forEach(subject => {
initialGrades[subject.id] = {};
periods.forEach(period => {
const periodGrades: any = {};
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim() &&
!!subsMap[String(e.id).trim()]
);
if (linkedExams.length > 0) {
linkedExams.forEach(exam => {
const existingGrade = dbNotas.find(g =>
String(g.disciplina_id).trim() === String(subject.id).trim() &&
(String(g.periodo_id).trim() === String(period.id).trim() || String(g.periodo_id).trim() === String(period.name).trim()) &&
String(g.prova_id).trim() === String(exam.id).trim()
);
periodGrades[exam.id] = existingGrade ? Number(existingGrade.valor) : '';
});
} else {
const existingGrade = dbNotas.find(g =>
String(g.disciplina_id).trim() === String(subject.id).trim() &&
(String(g.periodo_id).trim() === String(period.id).trim() || String(g.periodo_id).trim() === String(period.name).trim()) &&
!g.prova_id
);
periodGrades['direct'] = existingGrade ? Number(existingGrade.valor) : '';
}
initialGrades[subject.id][period.id] = periodGrades;
});
});
setStudentGrades(initialGrades);
};
const handleSaveGrades = async () => {
if (!selectedStudent) return;
const notasPayload: any[] = [];
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
Object.entries(periodGrades).forEach(([periodId, examValues]) => {
Object.entries(examValues).forEach(([examId, value]) => {
const numValue = Number(value);
if (numValue > 0 || (value !== '' && numValue === 0)) {
notasPayload.push({
aluno_id: selectedStudent.id,
disciplina_id: subjectId,
periodo_id: periodId,
prova_id: examId !== 'direct' ? examId : null,
valor: numValue
});
}
});
});
});
try {
const res = await fetch('/api/notas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notas: notasPayload })
});
if (res.ok) {
setSelectedStudent(null);
showAlert('Sucesso', '✅ Notas salvas com sucesso no banco de dados!', 'success');
} else {
showAlert('Erro', '❌ Falha ao salvar notas.', 'error');
}
} catch(e) {
console.error(e);
showAlert('Erro', '❌ Erro de conexão ao salvar notas.', 'error');
}
};
const calculateGeneralAverage = () => {
let totalSum = 0;
let totalCount = 0;
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
const periodAvgs: number[] = [];
Object.values(subjectPeriods).forEach((examValues: any) => {
const validValues = Object.values(examValues).filter(v => v !== '');
if (validValues.length > 0) {
const sum: number = validValues.reduce<number>((a, b: any) => a + Number(b), 0);
periodAvgs.push(sum / validValues.length);
}
});
if (periodAvgs.length > 0) {
const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length;
totalSum += subjectAvg;
totalCount++;
}
});
return totalCount > 0 ? (totalSum / totalCount).toFixed(2) : '0.00';
};
const getStudentGeneralAverage = (studentId: string) => {
// Priorizar notas do Postgres (classGrades) sobre o JSON
const studentGradesList = classGrades.length > 0
? classGrades.filter(g => g.studentId === studentId)
: grades.filter(g => g.studentId === studentId);
if (studentGradesList.length === 0) return '0.00';
const subjectAverages: number[] = [];
const subjectsWithGrades = new Set(studentGradesList.map(g => g.subjectId));
subjectsWithGrades.forEach(subId => {
const subGrades = studentGradesList.filter(g => g.subjectId === subId);
const periodValues: Record<string, number[]> = {};
subGrades.forEach(g => {
if (!periodValues[g.period]) periodValues[g.period] = [];
periodValues[g.period].push(g.value);
});
const periodAvgs: number[] = [];
Object.values(periodValues).forEach(values => {
if (values.length > 0) {
const sum = values.reduce((a, b) => a + b, 0);
periodAvgs.push(sum / values.length);
}
});
if (periodAvgs.length > 0) {
const totalSum = periodAvgs.reduce((a, b) => a + b, 0);
subjectAverages.push(totalSum / periodAvgs.length);
}
});
if (subjectAverages.length === 0) return '0.00';
const totalSum = subjectAverages.reduce((a, b) => a + b, 0);
return (totalSum / subjectAverages.length).toFixed(2);
};
const filteredClasses = dbClasses.filter(c =>
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2>
<p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p>
</div>
<button
onClick={() => setShowConfigManager(!showConfigManager)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
>
<Plus size={18} /> {showConfigManager ? 'Ver Boletins' : 'Configurações'}
</button>
</header>
{showConfigManager ? (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-6 animate-in slide-in-from-top-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Plus size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3>
</div>
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setConfigTab('subjects')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
DISCIPLINAS
</button>
<button
onClick={() => setConfigTab('periods')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
PERÍODOS
</button>
</div>
</div>
{configTab === 'subjects' ? (
<div className="space-y-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Nome da disciplina (ex: Matemática, Inglês...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newSubjectName}
onChange={(e) => setNewSubjectName(e.target.value)}
/>
<button
onClick={handleAddSubject}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
>
<Plus size={18} /> Adicionar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{subjects.map(subject => (
<div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{subject.name}</span>
<button
onClick={() => handleDeleteSubject(subject.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
{subjects.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhuma disciplina cadastrada.</div>
)}
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newPeriodName}
onChange={(e) => setNewPeriodName(e.target.value)}
/>
<button
onClick={handleAddPeriod}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
>
<Plus size={18} /> Adicionar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{periods.map(period => (
<div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{period.name}</span>
<button
onClick={() => handleDeletePeriod(period.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
{periods.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhum período cadastrado.</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-6">
{!selectedClass ? (
<>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar turmas..."
className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredClasses.map(cls => {
const course = dbCourses.find(c => c.id === cls.courseId);
const studentCount = data.students.filter(s => s.classId === cls.id).length;
return (
<div
key={cls.id}
onClick={() => setSelectedClass(cls)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<BookOpen size={80} />
</div>
<div className="flex items-center gap-4 mb-4">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-colors">
<GraduationCap size={24} />
</div>
<div>
<h3 className="font-black text-slate-800 text-lg">{cls.name}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{course?.nome || course?.name || 'Curso não encontrado'}</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500 font-medium">{studentCount} Alunos Matriculados</span>
<ChevronRight size={20} className="text-slate-300 group-hover:text-indigo-500 transform group-hover:translate-x-1 transition-all" />
</div>
</div>
);
})}
</div>
</>
) : (
<div className="space-y-6 animate-in slide-in-from-left-4">
<button
onClick={() => setSelectedClass(null)}
className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors"
>
<X size={18} /> Voltar para Turmas
</button>
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl">
<div className="flex items-center justify-between mb-8">
<div>
<h3 className="text-2xl font-black text-slate-800">{selectedClass.name}</h3>
<p className="text-slate-500 font-medium">Selecione um aluno para preencher as notas.</p>
</div>
<div className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-xl font-bold text-sm">
{data.students.filter(s => s.classId === selectedClass.id).length} Alunos
</div>
</div>
<div className="space-y-3">
{data.students
.filter(s => s.classId === selectedClass.id)
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => (
<div
key={student.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
{student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : (
<User size={20} />
)}
</div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
</div>
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
<div className="hidden sm:flex flex-col items-end">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
{getStudentGeneralAverage(student.id)}
</span>
</div>
<button
onClick={() => handleOpenStudentGrades(student)}
className="px-3 py-1.5 bg-white text-indigo-600 border border-indigo-100 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-xs flex items-center gap-1.5 shadow-sm"
>
<FileText size={14} /> Notas
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* GRADES MODAL */}
{selectedStudent && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 animate-in fade-in duration-300">
<div className="bg-white rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh] animate-slide-up">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white shadow-sm flex items-center justify-center text-indigo-600">
<GraduationCap size={28} />
</div>
<div>
<h3 className="text-xl font-black text-slate-800">{selectedStudent.name}</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar {selectedClass?.name}</p>
</div>
</div>
<button
onClick={() => setSelectedStudent(null)}
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"
>
<X size={20} />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-8 custom-scrollbar">
{subjects.length === 0 || periods.length === 0 ? (
<div className="text-center py-12 space-y-4">
<AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" />
<p className="text-slate-500 font-medium">
{subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'}
Por favor, complete as configurações primeiro.
</p>
<button
onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm"
>
Ir para Configurações
</button>
</div>
) : (
<div className="space-y-6">
{subjects.map(subject => {
// Encontrar provas vinculadas a esta disciplina
const linkedExams = (data.exams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim());
return (
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-indigo-600">
<BookOpen size={18} />
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
<div className="flex items-center gap-2">
{(() => {
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
!!studentSubmissions[String(e.id).trim()]
);
const provasCount = linkedExams.filter(e => (e as any).evaluationType !== 'activity').length;
const atividadesCount = linkedExams.filter(e => (e as any).evaluationType === 'activity').length;
return (
<>
{provasCount > 0 && (
<div className="px-3 py-1 bg-violet-50 border border-violet-200 rounded-lg text-[10px] font-black text-violet-600 flex items-center gap-1">
<FileText size={12} />
{provasCount} {provasCount === 1 ? 'Prova' : 'Provas'}
</div>
)}
{atividadesCount > 0 && (
<div className="px-3 py-1 bg-sky-50 border border-sky-200 rounded-lg text-[10px] font-black text-sky-600 flex items-center gap-1">
<FileText size={12} />
{atividadesCount} {atividadesCount === 1 ? 'Atividade' : 'Atividades'}
</div>
)}
</>
);
})()}
<div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500">
MÉDIA: {(() => {
const subjectGrades = studentGrades[subject.id] || {};
const pAvgs: number[] = [];
Object.values(subjectGrades).forEach((exVals: any) => {
const validValues = Object.values(exVals).filter(v => v !== '');
if (validValues.length > 0) {
pAvgs.push(validValues.reduce<number>((a, b: any) => a + Number(b), 0) / validValues.length);
}
});
return pAvgs.length > 0 ? (pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length).toFixed(1) : '0.0';
})()}
</div>
</div>
</div>
<div className="flex flex-col gap-6">
{periods.map(period => {
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim() &&
!!studentSubmissions[String(e.id).trim()]
);
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const validPeriodValues = Object.values(periodGrades).filter(v => v !== '');
const periodAvg: number = validPeriodValues.length > 0
? validPeriodValues.reduce<number>((a, b: any) => a + Number(b), 0) / validPeriodValues.length
: 0;
return (
<div key={period.id} className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm space-y-3 relative">
<div className="flex items-center justify-between border-b border-slate-100 pb-2 mb-2">
<label className="block text-xs font-black text-slate-700 uppercase tracking-widest">{period.name}</label>
<span className="text-[10px] font-bold bg-slate-100 text-slate-600 px-2 py-1 rounded-md">Média: {periodAvg.toFixed(1)}</span>
</div>
{linkedExams.length > 0 ? (
<div className="space-y-4">
{linkedExams.map(exam => {
const isActivity = (exam as any).evaluationType === 'activity';
const maxScore = (exam as any).maxScore ?? 10;
return (
<div key={exam.id} className={`p-4 rounded-xl border flex flex-col md:flex-row md:items-center justify-between gap-4 ${isActivity ? 'bg-sky-50/50 border-sky-100' : 'bg-violet-50/50 border-violet-100'}`}>
<div className="flex flex-col">
<div className="text-sm font-bold text-slate-800 leading-tight mb-1 flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-black shrink-0 ${isActivity ? 'bg-sky-200 text-sky-800' : 'bg-violet-200 text-violet-800'}`}>
{isActivity ? 'Atividade' : 'Prova'}
</span>
{exam.title}
</div>
<div className="flex flex-col gap-1">
{exam.description && (
<p className="text-xs text-slate-500 leading-snug pr-2">{exam.description}</p>
)}
{(() => {
const stats = studentSubmissions[String(exam.id).trim()];
if (!stats) return null;
return (
<div className="flex gap-2 mt-2">
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 border border-emerald-200 flex items-center gap-1">
<CheckCircle2 size={10} /> {stats.acertos} Acertos
</span>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 border border-rose-200 flex items-center gap-1">
<X size={10} /> {stats.erros} Erros
</span>
</div>
);
})()}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[10px] font-bold text-slate-500 uppercase">Nota (Máx {maxScore})</span>
<input
type="number"
min="0"
max={maxScore}
step="0.1"
placeholder="—"
className={`w-24 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-all text-sm font-black text-center ${isActivity ? 'bg-white border-sky-200 focus:ring-sky-500' : 'bg-white border-violet-200 focus:ring-violet-500'}`}
value={studentGrades[subject.id]?.[period.id]?.[exam.id] ?? ''}
onChange={(e) => {
let val = parseFloat(e.target.value);
if (val > maxScore) val = maxScore;
if (val < 0) val = 0;
setStudentGrades(prev => ({
...prev,
[subject.id]: {
...prev[subject.id],
[period.id]: {
...prev[subject.id]?.[period.id],
[exam.id]: isNaN(val) ? '' : val
}
}
}));
}}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="space-y-1.5 mt-2">
<p className="text-[9px] text-slate-400 font-medium mb-1">Nota Direta (Sem avaliação vinculada)</p>
<input
type="number"
min="0"
max="10"
step="0.1"
placeholder="—"
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center"
value={studentGrades[subject.id]?.[period.id]?.direct ?? ''}
onChange={(e) => {
let val = parseFloat(e.target.value);
if (val > 10) val = 10;
if (val < 0) val = 0;
setStudentGrades(prev => ({
...prev,
[subject.id]: {
...prev[subject.id],
[period.id]: {
direct: isNaN(val) ? '' : val
}
}
}));
}}
/>
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
{/* General Average Summary */}
<div className="bg-indigo-600 rounded-2xl p-6 text-white flex items-center justify-between shadow-xl shadow-indigo-100">
<div className="flex items-center gap-3">
<div className="p-3 bg-white/20 rounded-xl">
<Calculator size={24} />
</div>
<div>
<h4 className="text-lg font-black">Média Geral</h4>
<p className="text-xs text-indigo-100 font-medium">Calculada automaticamente com base em todas as disciplinas.</p>
</div>
</div>
<div className="text-4xl font-black">
{calculateGeneralAverage()}
</div>
</div>
</div>
)}
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setSelectedStudent(null)}
className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all"
>
Cancelar
</button>
<button
onClick={handleSaveGrades}
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2"
>
<Save size={18} /> Salvar Notas
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ReportCard;