feat: trava de duplicidade financeira, boletim multiavaliações e melhorias no portal

This commit is contained in:
Sidney 2026-04-29 08:52:42 -03:00
parent 065476df16
commit 2f50468cc5
9 changed files with 518 additions and 295 deletions

View File

@ -12,13 +12,11 @@
- [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager). - [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager).
- [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU. - [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU.
- [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino. - [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino.
- [x] **Mensagens e Automação Financeira:** Implementado sistema seletivo de disparos (Atrasados vs Preventivos) com botões independentes e lógica de servidor desacoplada. - [x] **Segurança Financeira:** Implementado estado de carregamento (`isCreating`) no botão de gerar cobranças para impedir disparos duplicados ao Asaas por cliques múltiplos.
- [x] **Configuração Contextual:** Configurações de automação (dias antes/depois, repetições) movidas da sidebar global para dentro dos modais de cada modelo de mensagem. - [x] **Boletim & Avaliações:** Refatoração completa do sistema de notas para suportar múltiplas avaliações por período, integrando notas diretas e notas vindas de provas/atividades online.
- [x] **Refinamento de UX/UI:** Correção de modais de Frequência para padrão `bg-transparent` e adição de cor `indigo` para identificação de lembretes preventivos. - [x] **Sincronia Portal/Manager:** Ajustada a submissão de provas no Portal para calcular notas via `maxScore` e injetar automaticamente no boletim do Gerenciador via `examId`.
- [x] Registro de Frequência: Implementado ícone de olho para abrir modal com texto completo da justificativa e botão de aprovação rápida. - [x] **Padronização de Servidor:** Confirmado o uso de `server.selfhosted.js` em ambos os apps como ponto de entrada para garantir 100% de funcionalidades locais.
- [x] Padronização Visual: Modais atualizados para `bg-transparent` (sem escurecimento/blur) com `shadow-2xl` para efeito de flutuação premium. - [ ] Próximo Passo: Monitorar o desempenho das submissões de provas simultâneas no Portal.
- [x] Backup & Sincronia: Portal agora envia metadados de justificativa filtráveis pelo Gerenciador.
- [ ] Próximo Passo: Analisar logs de comportamento dos disparos preventivos em larga escala.
### 💳 Módulo Financeiro (Portal do Aluno) ### 💳 Módulo Financeiro (Portal do Aluno)
- **Funcionalidades Implementadas:** - **Funcionalidades Implementadas:**
@ -29,6 +27,13 @@
- Visualização de recibos via link externo ou modal de impressão local. - Visualização de recibos via link externo ou modal de impressão local.
- **Onde paramos:** O sistema de filtros e ordenação está funcional, sincronizando com os parâmetros da URL. - **Onde paramos:** O sistema de filtros e ordenação está funcional, sincronizando com os parâmetros da URL.
### 📝 Módulo de Avaliações (Portal do Aluno)
- **Funcionalidades Implementadas:**
- Tela de realização de provas e atividades online com cronômetro e suporte a imagens de apoio (MinIO).
- **Autocorreção 100% Automática:** O backend do portal (`server.js`) recebe as respostas, compara com o gabarito (`correctOptionIndex`), calcula o percentual de acertos e a nota proporcional ao peso da prova (`finalScore`).
- **Lançamento Automático no Boletim:** A nota calculada é salva no PostgreSQL (`provas_submissoes`) e injetada instantaneamente na tabela de notas (`grades`) do `school_data`.
- Bloqueio inteligente contra dupla submissão da mesma prova.
### ⚙️ Módulo de Configurações e Infra (Manager) ### ⚙️ Módulo de Configurações e Infra (Manager)
- **Arquitetura de Armazenamento:** Implementada a transição para **Self-Hosted Storage (MinIO)**. - **Arquitetura de Armazenamento:** Implementada a transição para **Self-Hosted Storage (MinIO)**.
- Extração de Base64 concluída com sucesso via `migrate_images_to_minio.ts`. - Extração de Base64 concluída com sucesso via `migrate_images_to_minio.ts`.

View File

@ -20,7 +20,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
try { try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/); const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`; if (match) return `/storage/${match[1]}`;
} catch(e) {} } catch (e) { }
return url; return url;
}; };
@ -38,8 +38,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
classId: data.classes[0]?.id || '', classId: data.classes[0]?.id || '',
durationMinutes: 60, durationMinutes: 60,
status: 'draft', status: 'draft',
questions: [] questions: [],
}); evaluationType: 'exam',
maxScore: 10
} as any);
setCurrentView('builder'); setCurrentView('builder');
}; };
@ -186,16 +188,38 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<input <input
type="text" type="text"
value={editingExam.title} value={editingExam.title}
onChange={e => setEditingExam({...editingExam, title: e.target.value})} onChange={e => setEditingExam({ ...editingExam, title: e.target.value })}
placeholder="Ex: Prova Bimestral de Matemática" placeholder="Ex: Prova Bimestral de Matemática"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800" className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/> />
</div> </div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Tipo de Avaliação</label>
<select
value={(editingExam as any).evaluationType || 'exam'}
onChange={e => setEditingExam({ ...editingExam, evaluationType: e.target.value } as any)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="exam">Prova</option>
<option value="activity">Atividade</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Valor (Pontuação Máxima)</label>
<input
type="number"
value={(editingExam as any).maxScore ?? 10}
onChange={e => setEditingExam({ ...editingExam, maxScore: parseFloat(e.target.value) || 0 } as any)}
min="0"
step="0.1"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/>
</div>
<div> <div>
<label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label> <label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label>
<select <select
value={editingExam.classId} value={editingExam.classId}
onChange={e => setEditingExam({...editingExam, classId: e.target.value})} onChange={e => setEditingExam({ ...editingExam, classId: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800" className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
> >
<option value="" disabled>Selecione uma turma</option> <option value="" disabled>Selecione uma turma</option>
@ -209,7 +233,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<input <input
type="number" type="number"
value={editingExam.durationMinutes} value={editingExam.durationMinutes}
onChange={e => setEditingExam({...editingExam, durationMinutes: parseInt(e.target.value) || 0})} onChange={e => setEditingExam({ ...editingExam, durationMinutes: parseInt(e.target.value) || 0 })}
min="0" min="0"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800" className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/> />
@ -218,7 +242,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<label className="block text-sm font-bold text-slate-700 mb-2">Disciplina (Boletim)</label> <label className="block text-sm font-bold text-slate-700 mb-2">Disciplina (Boletim)</label>
<select <select
value={editingExam.subjectId || ''} value={editingExam.subjectId || ''}
onChange={e => setEditingExam({...editingExam, subjectId: e.target.value || undefined})} onChange={e => setEditingExam({ ...editingExam, subjectId: e.target.value || undefined })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800" className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
> >
<option value="">Nenhuma (não vincular)</option> <option value="">Nenhuma (não vincular)</option>
@ -232,7 +256,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<label className="block text-sm font-bold text-slate-700 mb-2">Período (Boletim)</label> <label className="block text-sm font-bold text-slate-700 mb-2">Período (Boletim)</label>
<select <select
value={editingExam.periodId || ''} value={editingExam.periodId || ''}
onChange={e => setEditingExam({...editingExam, periodId: e.target.value || undefined})} onChange={e => setEditingExam({ ...editingExam, periodId: e.target.value || undefined })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800" className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
> >
<option value="">Nenhum (não vincular)</option> <option value="">Nenhum (não vincular)</option>
@ -443,10 +467,20 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<div key={exam.id} className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow relative overflow-hidden group"> <div key={exam.id} className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow relative overflow-hidden group">
<div className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div> <div className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3> <div className="flex flex-col gap-1">
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 ${ <h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700' <div className="flex items-center gap-2 mt-1">
}`}> <span className={`px-2 py-0.5 text-[10px] font-black uppercase tracking-wider rounded-md ${(exam as any).evaluationType === 'activity' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'
}`}>
{(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
</span>
<span className="text-xs font-bold text-slate-500">
Vale: {(exam as any).maxScore ?? 10} pts
</span>
</div>
</div>
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 mt-1 ${exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
}`}>
{exam.status === 'published' ? 'Publicada' : 'Rascunho'} {exam.status === 'published' ? 'Publicada' : 'Rascunho'}
</span> </span>
</div> </div>

View File

@ -51,6 +51,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [isFetchingCarne, setIsFetchingCarne] = useState(false); const [isFetchingCarne, setIsFetchingCarne] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
React.useEffect(() => { React.useEffect(() => {
syncAsaasPayments(); syncAsaasPayments();
@ -494,6 +495,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
const handleCreatePayment = async (e: React.FormEvent) => { const handleCreatePayment = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isCreating) return;
if (!formData.studentId || formData.amount <= 0) { if (!formData.studentId || formData.amount <= 0) {
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e informe um valor válido.', 'warning'); showAlert('Atenção', '⚠️ Por favor, selecione um aluno e informe um valor válido.', 'warning');
@ -506,7 +508,9 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
return; return;
} }
const newPayments: Payment[] = []; setIsCreating(true);
try {
const newPayments: Payment[] = [];
let baseDateStr = formData.dueDate; let baseDateStr = formData.dueDate;
if (dueDateDisplay.length === 10) { if (dueDateDisplay.length === 10) {
@ -573,16 +577,19 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
// Validação de campos obrigatórios para o Asaas Oficial // Validação de campos obrigatórios para o Asaas Oficial
if (!finalCpf || finalCpf.length < 11) { if (!finalCpf || finalCpf.length < 11) {
showAlert('Erro de Cadastro', `O ${isMinor ? 'responsável' : 'aluno'} precisa ter um CPF válido cadastrado para gerar cobrança no Asaas Oficial.`, 'error'); showAlert('Erro de Cadastro', `O ${isMinor ? 'responsável' : 'aluno'} precisa ter um CPF válido cadastrado para gerar cobrança no Asaas Oficial.`, 'error');
setIsCreating(false);
return; return;
} }
if (!student.addressZip || student.addressZip.length < 8) { if (!student.addressZip || student.addressZip.length < 8) {
showAlert('Erro de Cadastro', 'O CEP do aluno é obrigatório e deve ser válido para o Asaas Oficial.', 'error'); showAlert('Erro de Cadastro', 'O CEP do aluno é obrigatório e deve ser válido para o Asaas Oficial.', 'error');
setIsCreating(false);
return; return;
} }
if (!student.addressStreet || !student.addressNumber) { if (!student.addressStreet || !student.addressNumber) {
showAlert('Erro de Cadastro', 'Endereço e Número são obrigatórios no cadastro do aluno para gerar cobrança.', 'error'); showAlert('Erro de Cadastro', 'Endereço e Número são obrigatórios no cadastro do aluno para gerar cobrança.', 'error');
setIsCreating(false);
return; return;
} }
@ -667,6 +674,9 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
}); });
showAlert('Sucesso', 'Nova cobrança gerada com sucesso.', 'success'); showAlert('Sucesso', 'Nova cobrança gerada com sucesso.', 'success');
closeModal(); closeModal();
} finally {
setIsCreating(false);
}
}; };
const closeModal = () => { const closeModal = () => {
@ -1317,7 +1327,15 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
</div> </div>
<div className="pt-4 flex gap-4"> <div className="pt-4 flex gap-4">
<button type="button" onClick={closeModal} className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold text-xs">Cancelar</button> <button type="button" onClick={closeModal} className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold text-xs">Cancelar</button>
<button type="submit" className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold text-xs">Gerar Lançamento</button> <button type="submit" disabled={isCreating} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold text-xs disabled:opacity-50 disabled:cursor-wait">
{isCreating ? (
<span className="flex items-center justify-center gap-2">
<RefreshCw size={14} className="animate-spin" /> Gerando...
</span>
) : (
'Gerar Lançamento'
)}
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -33,7 +33,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showConfigManager, setShowConfigManager] = useState(false); const [showConfigManager, setShowConfigManager] = useState(false);
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects'); const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, number>>>({}); // subjectId -> periodId -> value const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
const subjects = data.subjects || []; const subjects = data.subjects || [];
const periods = data.periods || []; const periods = data.periods || [];
@ -48,7 +48,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
try { try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/); const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`; if (match) return `/storage/${match[1]}`;
} catch(e) {} } catch (e) { }
return url; return url;
}; };
@ -111,13 +111,24 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const handleOpenStudentGrades = (student: Student) => { const handleOpenStudentGrades = (student: Student) => {
setSelectedStudent(student); setSelectedStudent(student);
const initialGrades: Record<string, Record<string, number>> = {}; const initialGrades: Record<string, Record<string, any>> = {};
subjects.forEach(subject => { subjects.forEach(subject => {
initialGrades[subject.id] = {}; initialGrades[subject.id] = {};
periods.forEach(period => { periods.forEach(period => {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id); const periodGrades: any = {};
initialGrades[subject.id][period.id] = existingGrade ? existingGrade.value : 0; const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
if (linkedExams.length > 0) {
linkedExams.forEach(exam => {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id && (g as any).examId === exam.id);
periodGrades[exam.id] = existingGrade ? existingGrade.value : '';
});
} else {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id && !(g as any).examId);
periodGrades['direct'] = existingGrade ? existingGrade.value : '';
}
initialGrades[subject.id][period.id] = periodGrades;
}); });
}); });
@ -130,16 +141,20 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const newGradesList: Grade[] = [...grades.filter(g => g.studentId !== selectedStudent.id)]; const newGradesList: Grade[] = [...grades.filter(g => g.studentId !== selectedStudent.id)];
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => { Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
Object.entries(periodGrades).forEach(([periodId, value]) => { Object.entries(periodGrades).forEach(([periodId, examValues]) => {
if (value > 0) { Object.entries(examValues).forEach(([examId, value]) => {
newGradesList.push({ const numValue = Number(value);
id: crypto.randomUUID(), if (numValue > 0 || (value !== '' && numValue === 0)) {
studentId: selectedStudent.id, newGradesList.push({
subjectId, id: crypto.randomUUID(),
period: periodId, studentId: selectedStudent.id,
value subjectId,
}); period: periodId,
} value: numValue,
...(examId !== 'direct' ? { examId } : {})
} as Grade);
}
});
}); });
}); });
@ -153,11 +168,18 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
let totalSum = 0; let totalSum = 0;
let totalCount = 0; let totalCount = 0;
Object.values(studentGrades).forEach(subjectPeriods => { Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
const periodValues = Object.values(subjectPeriods).filter((v): v is number => typeof v === 'number' && v > 0); const periodSums: number[] = [];
if (periodValues.length > 0) {
const subjectSum = periodValues.reduce((a, b) => a + b, 0); Object.values(subjectPeriods).forEach((examValues: any) => {
const subjectAvg = subjectSum / periodValues.length; const sum = Object.values(examValues).reduce((a: number, b: any) => a + (b !== '' ? Number(b) : 0), 0);
if (Object.values(examValues).some(v => v !== '')) {
periodSums.push(sum);
}
});
if (periodSums.length > 0) {
const subjectAvg = periodSums.reduce((a, b) => a + b, 0) / periodSums.length;
totalSum += subjectAvg; totalSum += subjectAvg;
totalCount++; totalCount++;
} }
@ -175,8 +197,17 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
subjectsWithGrades.forEach(subId => { subjectsWithGrades.forEach(subId => {
const subGrades = studentGradesList.filter(g => g.subjectId === subId); const subGrades = studentGradesList.filter(g => g.subjectId === subId);
const sum = subGrades.reduce((a, b) => a + b.value, 0);
subjectAverages.push(sum / subGrades.length); const periodSums: Record<string, number> = {};
subGrades.forEach(g => {
periodSums[g.period] = (periodSums[g.period] || 0) + g.value;
});
const periodsCount = Object.keys(periodSums).length;
if (periodsCount > 0) {
const totalSum = Object.values(periodSums).reduce((a, b) => a + b, 0);
subjectAverages.push(totalSum / periodsCount);
}
}); });
if (subjectAverages.length === 0) return '0.00'; if (subjectAverages.length === 0) return '0.00';
@ -371,36 +402,36 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
.filter(s => s.classId === selectedClass.id) .filter(s => s.classId === selectedClass.id)
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(student => ( .map(student => (
<div <div
key={student.id} 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" 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="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"> <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 ? ( {student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" /> <img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : ( ) : (
<User size={20} /> <User size={20} />
)} )}
</div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
</div> </div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span> <div className="flex items-center gap-4 flex-shrink-0 ml-4">
</div> <div className="hidden sm:flex flex-col items-end">
<div className="flex items-center gap-4 flex-shrink-0 ml-4"> <span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
<div className="hidden sm:flex flex-col items-end"> <span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span> {getStudentGeneralAverage(student.id)}
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}> </span>
{getStudentGeneralAverage(student.id)} </div>
</span> <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>
<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> </div>
</div> </div>
@ -452,64 +483,119 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published'); const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published');
return ( return (
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4"> <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 justify-between">
<div className="flex items-center gap-2 text-indigo-600"> <div className="flex items-center gap-2 text-indigo-600">
<BookOpen size={18} /> <BookOpen size={18} />
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4> <h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
<div className="flex items-center gap-2">
{linkedExams.length > 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} />
{linkedExams.length} {linkedExams.length === 1 ? 'Prova' : 'Provas'}
</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 vals = Object.values(subjectGrades).filter((v): v is number => typeof v === 'number' && v > 0);
return vals.length > 0 ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : '0.0';
})()}
</div> </div>
</div> <div className="flex items-center gap-2">
</div> {linkedExams.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <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">
{periods.map(period => { <FileText size={12} />
// Verificar se há uma prova vinculada a esta disciplina+período {linkedExams.length} {linkedExams.length === 1 ? 'Prova' : 'Provas'}
const linkedExam = (data.exams || []).find(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published'); </div>
return (
<div key={period.id} className="space-y-1.5">
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">{period.name}</label>
<input
type="number"
min="0"
max="10"
step="0.1"
className={`w-full px-3 py-2 bg-white border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center ${linkedExam ? 'border-violet-300 ring-1 ring-violet-100' : 'border-slate-200'}`}
value={studentGrades[subject.id]?.[period.id] || 0}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
setStudentGrades(prev => ({
...prev,
[subject.id]: {
...prev[subject.id],
[period.id]: val
}
}));
}}
/>
{linkedExam && (
<p className="text-[9px] font-bold text-violet-500 truncate ml-1" title={linkedExam.title}>
📝 {linkedExam.title}
</p>
)} )}
<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 pSums = Object.values(subjectGrades).map((exVals: any) => Object.values(exVals).reduce((a: number, b: any) => a + (b !== '' ? Number(b) : 0), 0));
const valid = pSums.filter(s => s > 0);
return valid.length > 0 ? (valid.reduce((a, b) => a + b, 0) / valid.length).toFixed(1) : '0.0';
})()}
</div>
</div> </div>
); </div>
})} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{periods.map(period => {
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const periodSum = Object.values(periodGrades).reduce((a: number, b: any) => a + (b !== '' ? Number(b) : 0), 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">Total: {periodSum.toFixed(1)}</span>
</div>
{linkedExams.length > 0 ? (
<div className="space-y-3">
{linkedExams.map(exam => {
const isActivity = (exam as any).evaluationType === 'activity';
const maxScore = (exam as any).maxScore ?? 10;
return (
<div key={exam.id} className="space-y-1">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-slate-600 truncate pr-2" title={exam.title}>
<span className={`mr-1 px-1.5 py-0.5 rounded text-[8px] uppercase tracking-wider ${isActivity ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
{isActivity ? 'Ativ' : 'Prova'}
</span>
{exam.title}
</span>
<span className="text-[9px] text-slate-400 font-bold whitespace-nowrap shrink-0">Vale {maxScore}</span>
</div>
<input
type="number"
min="0"
max={maxScore}
step="0.1"
placeholder="—"
className="w-full px-2 py-1.5 bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center"
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 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> </div>
</div>
); );
})} })}

View File

@ -37,6 +37,7 @@ O EduManager armazena TODOS os dados da escola em uma **única tabela** chamada
attendance: Attendance[], // Registros de presença attendance: Attendance[], // Registros de presença
subjects: Subject[], // Disciplinas subjects: Subject[], // Disciplinas
grades: Grade[], // Notas dos alunos grades: Grade[], // Notas dos alunos
exams: Exam[], // Avaliações (Provas e Atividades)
profile: SchoolProfile, // Dados da escola (nome, logo, etc.) profile: SchoolProfile, // Dados da escola (nome, logo, etc.)
logo?: string, // Logo da escola em base64 logo?: string, // Logo da escola em base64
} }
@ -105,6 +106,19 @@ interface Grade {
subjectId: string; subjectId: string;
value: number; value: number;
period: string; // Ex: "1º Bimestre" period: string; // Ex: "1º Bimestre"
examId?: string; // ID da avaliação vinculada (se houver)
}
```
### Interface Exam (avaliações):
```typescript
interface Exam {
id: string;
title: string;
evaluationType: 'exam' | 'activity';
maxScore: number;
subjectId?: string;
periodId?: string;
} }
``` ```

View File

@ -257,7 +257,7 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, async (req, res) =
} }
let attachment = null; let attachment = null;
try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) {} try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) { }
notifications.push({ notifications.push({
id: `notif-${Date.now()}`, studentId: 'admin', id: `notif-${Date.now()}`, studentId: 'admin',

View File

@ -250,7 +250,14 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId); const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
const enrichedGrades = grades.map((g) => { const enrichedGrades = grades.map((g) => {
const subject = subjects.find((s) => s.id === g.subjectId); const subject = subjects.find((s) => s.id === g.subjectId);
return { ...g, subjectName: subject?.name || 'Disciplina desconhecida' }; const exam = g.examId ? (schoolData.exams || []).find(e => e.id === g.examId) : null;
return {
...g,
subjectName: subject?.name || 'Disciplina desconhecida',
examTitle: exam?.title,
evaluationType: exam?.evaluationType || 'exam',
maxScore: exam?.maxScore
};
}); });
const periods = [...new Set(grades.map((g) => g.period))]; const periods = [...new Set(grades.map((g) => g.period))];
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre'); if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
@ -531,7 +538,8 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
const wrongCount = totalQuestions - correctCount; const wrongCount = totalQuestions - correctCount;
const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0; const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0;
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 10).toFixed(2)) : 0; const maxScore = exam.maxScore != null ? Number(exam.maxScore) : 10;
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * maxScore).toFixed(2)) : 0;
// Salvar no PostgreSQL // Salvar no PostgreSQL
await pool.query( await pool.query(
@ -543,11 +551,18 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
// Integrar com grades no school_data // Integrar com grades no school_data
if (exam.subjectId && exam.periodId) { if (exam.subjectId && exam.periodId) {
const grades = schoolData.grades || []; const grades = schoolData.grades || [];
const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId); const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId && g.examId === examId);
if (existingGradeIndex >= 0) { if (existingGradeIndex >= 0) {
grades[existingGradeIndex].value = finalScore; grades[existingGradeIndex].value = finalScore;
} else { } else {
grades.push({ id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`, studentId: req.user.studentId, subjectId: exam.subjectId, period: exam.periodId, value: finalScore }); grades.push({
id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`,
studentId: req.user.studentId,
subjectId: exam.subjectId,
period: exam.periodId,
value: finalScore,
examId: examId
});
} }
schoolData.grades = grades; schoolData.grades = grades;
await saveSchoolData(schoolData); await saveSchoolData(schoolData);

View File

@ -653,6 +653,24 @@ export default function Avaliacoes() {
)} )}
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '6px',
fontSize: '0.65rem',
fontWeight: 800,
textTransform: 'uppercase',
letterSpacing: '0.05em',
backgroundColor: (exam as any).evaluationType === 'activity' ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha, #ede9fe)',
color: (exam as any).evaluationType === 'activity' ? 'var(--color-info, #0369a1)' : 'var(--color-primary, #6d28d9)',
border: `1px solid ${(exam as any).evaluationType === 'activity' ? '#bae6fd' : '#ddd6fe'}`
}}>
{(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
</span>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--color-text-secondary)' }}>
Vale: {(exam as any).maxScore ?? 10} pts
</span>
</div>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: 4, paddingRight: isDone ? 90 : 0 }}> <h3 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: 4, paddingRight: isDone ? 90 : 0 }}>
{exam.title} {exam.title}
</h3> </h3>
@ -712,7 +730,7 @@ export default function Avaliacoes() {
boxShadow: '0 4px 15px var(--bg-primary-alpha)', boxShadow: '0 4px 15px var(--bg-primary-alpha)',
}} }}
> >
<Award size={18} /> Iniciar Prova <Award size={18} /> {(exam as any).evaluationType === 'activity' ? 'Iniciar Atividade' : 'Iniciar Prova'}
</button> </button>
)} )}
</div> </div>

View File

@ -1,10 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { BookOpen } from 'lucide-react'; import { BookOpen, FileText } from 'lucide-react';
import type { Grade, Subject } from '../types'; import type { Grade, Subject } from '../types';
interface GradeWithSubject extends Grade { interface GradeWithSubject extends Grade {
subjectName: string; subjectName: string;
examTitle?: string;
evaluationType?: string;
maxScore?: number;
} }
export default function Notas() { export default function Notas() {
@ -42,7 +45,6 @@ export default function Notas() {
); );
} }
// Use subjects from course instead of deriving from grades
const displaySubjects = allSubjects.length > 0 const displaySubjects = allSubjects.length > 0
? allSubjects ? allSubjects
: [...new Set(grades.map(g => g.subjectId))].map(id => ({ : [...new Set(grades.map(g => g.subjectId))].map(id => ({
@ -50,23 +52,26 @@ export default function Notas() {
name: grades.find(g => g.subjectId === id)?.subjectName || id name: grades.find(g => g.subjectId === id)?.subjectName || id
})); }));
// Logic for General Average: Only show if EVERY subject has a grade > 0 // General average logic
const isAllGraded = displaySubjects.length > 0 && displaySubjects.every(s => { const validGrades = grades.filter(g => g.value > 0);
const subjectId = typeof s === 'string' ? s : s.id; const totalAvg = displaySubjects.length > 0 && validGrades.length > 0
const subjectGrades = grades.filter(g => g.subjectId === subjectId); ? validGrades.reduce((s, g) => s + g.value, 0) / validGrades.length
return subjectGrades.length > 0 && subjectGrades.every(g => g.value > 0);
});
const totalAvg = isAllGraded
? grades.reduce((s, g) => s + g.value, 0) / (displaySubjects.length || 1)
: 0; : 0;
const getGradeColor = (value: number) => { const getGradeColor = (value: number, maxScore: number = 10) => {
if (value >= 7) return 'var(--color-success)'; const percentage = (value / maxScore) * 10;
if (value >= 5) return 'var(--color-warning)'; if (percentage >= 7) return 'var(--color-success)';
if (percentage >= 5) return 'var(--color-warning)';
return 'var(--color-danger)'; return 'var(--color-danger)';
}; };
const getBgColor = (value: number, maxScore: number = 10) => {
const percentage = (value / maxScore) * 10;
if (percentage >= 7) return 'var(--bg-success-alpha)';
if (percentage >= 5) return 'var(--bg-warning-alpha)';
return 'var(--bg-danger-alpha)';
};
return ( return (
<div className="page-container"> <div className="page-container">
<div className="animate-fade-in" style={{ <div className="animate-fade-in" style={{
@ -79,7 +84,7 @@ export default function Notas() {
}}> }}>
<div> <div>
<h1 className="page-title">Notas & Boletim</h1> <h1 className="page-title">Notas & Boletim</h1>
<p className="page-subtitle">Acompanhe seu desempenho acadêmico</p> <p className="page-subtitle">Acompanhe seu desempenho detalhado por disciplina</p>
</div> </div>
<div className="glass-card" style={{ <div className="glass-card" style={{
@ -97,9 +102,9 @@ export default function Notas() {
letterSpacing: '0.1em', letterSpacing: '0.1em',
marginBottom: '0.25rem', marginBottom: '0.25rem',
textTransform: 'uppercase' textTransform: 'uppercase'
}}>Média Geral</p> }}>Média Geral (Estimada)</p>
<p style={{ <p style={{
fontSize: isAllGraded ? '3rem' : '1.25rem', fontSize: totalAvg > 0 ? '3rem' : '1.25rem',
fontWeight: 800, fontWeight: 800,
color: 'var(--color-text-primary)', color: 'var(--color-text-primary)',
lineHeight: 1.2, lineHeight: 1.2,
@ -107,7 +112,7 @@ export default function Notas() {
marginBottom: 0, marginBottom: 0,
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}}> }}>
{isAllGraded ? totalAvg.toFixed(1) : 'Aguardando notas...'} {totalAvg > 0 ? totalAvg.toFixed(1) : 'Aguardando notas...'}
</p> </p>
</div> </div>
</div> </div>
@ -120,64 +125,92 @@ export default function Notas() {
<p style={{ fontSize: '0.9375rem' }}>Nenhuma matéria cadastrada no curso</p> <p style={{ fontSize: '0.9375rem' }}>Nenhuma matéria cadastrada no curso</p>
</div> </div>
) : ( ) : (
<div className="glass-card animate-fade-in" style={{ overflow: 'hidden' }}> <div className="stagger-children">
<div style={{ overflowX: 'auto' }}> {displaySubjects.map((subject, idx) => {
<table className="data-table"> const subjectId = typeof subject === 'string' ? subject : subject.id;
<thead> const subjectName = typeof subject === 'string' ? subject : subject.name;
<tr> const subjectGrades = grades.filter(g => g.subjectId === subjectId);
<th>Disciplina</th>
<th style={{ textAlign: 'center' }}>Nota / Média</th>
</tr>
</thead>
<tbody>
{displaySubjects.map((s, idx) => {
const subjectId = typeof s === 'string' ? s : s.id;
const subjectName = typeof s === 'string' ? s : s.name;
const subjectGrades = grades.filter(g => g.subjectId === subjectId); return (
const avg = subjectGrades.length > 0 <div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
? subjectGrades.reduce((sum, g) => sum + g.value, 0) / subjectGrades.length <div style={{ padding: '1.5rem', background: 'var(--color-surface-light)', borderBottom: '1px solid var(--glass-border)' }}>
: 0; <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-primary)' }}>{subjectName}</h2>
return ( </div>
<tr key={subjectId} style={{
animation: `fadeIn 0.3s ease-out ${idx * 0.05}s forwards`, <div style={{ padding: '1.5rem' }}>
opacity: 0, {periods.map(period => {
}}> const periodGrades = subjectGrades.filter(g => g.period === period);
<td style={{ fontWeight: 500 }}> if (periodGrades.length === 0) return null;
{subjectName}
</td> const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0);
<td style={{ textAlign: 'center' }}>
{subjectGrades.length > 0 && avg > 0 ? ( return (
<span style={{ <div key={period} style={{ marginBottom: '1.5rem' }}>
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem', borderBottom: '2px solid var(--glass-border)', paddingBottom: '0.5rem' }}>
minWidth: 48, height: 32, borderRadius: 8, padding: '0 8px', <h3 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-secondary)' }}>{period}</h3>
background: avg >= 7 ? 'var(--bg-success-alpha)' : avg >= 5 ? 'var(--bg-warning-alpha)' : 'var(--bg-danger-alpha)', <div style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--color-text)' }}>
fontWeight: 700, fontSize: '0.925rem', Total do Período: <span style={{ color: getGradeColor(periodTotal, 10) }}>{periodTotal.toFixed(1)}</span>
color: getGradeColor(avg), </div>
}}> </div>
{avg.toFixed(1)}
</span> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
) : ( {periodGrades.map((grade) => {
<span style={{ const isActivity = grade.evaluationType === 'activity';
padding: '4px 10px', const maxScore = grade.maxScore ?? 10;
background: 'var(--color-surface-light)', const isDirect = !grade.examId;
borderRadius: 6,
color: 'var(--color-text-secondary)', return (
fontSize: '0.7rem', <div key={grade.id} style={{
fontWeight: 600, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
textTransform: 'uppercase', padding: '0.75rem 1rem',
letterSpacing: '0.025em' background: 'var(--color-surface)',
}}> border: '1px solid var(--glass-border)',
Aguardando borderRadius: '12px'
</span> }}>
)} <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
</td> <div style={{
</tr> padding: '4px', borderRadius: '6px',
); background: isDirect ? 'var(--bg-warning-alpha)' : isActivity ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha)',
})} color: isDirect ? 'var(--color-warning)' : isActivity ? 'var(--color-info, #0369a1)' : 'var(--color-primary)'
</tbody> }}>
</table> <FileText size={16} />
</div> </div>
<div>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--color-text)' }}>
{isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
</div>
{!isDirect && (
<div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)' }}>
{isActivity ? 'ATIVIDADE' : 'PROVA'} VALE: {maxScore} PTS
</div>
)}
</div>
</div>
<div style={{
padding: '4px 12px', borderRadius: '8px',
background: getBgColor(grade.value, maxScore),
color: getGradeColor(grade.value, maxScore),
fontWeight: 800, fontSize: '0.9rem'
}}>
{grade.value.toFixed(1)}
</div>
</div>
);
})}
</div>
</div>
);
})}
{subjectGrades.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.85rem', padding: '1rem' }}>
Nenhuma nota lançada para esta disciplina.
</div>
)}
</div>
</div>
);
})}
</div> </div>
)} )}
@ -189,15 +222,15 @@ export default function Notas() {
}}> }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-success)' }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-success)' }} />
Aprovado ( 7.0) Bom Desempenho
</span> </span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-warning)' }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-warning)' }} />
Recuperação (5.0 - 6.9) Atenção
</span> </span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-danger)' }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-danger)' }} />
Reprovado (&lt; 5.0) Baixo Desempenho
</span> </span>
</div> </div>
</div> </div>