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,9 +467,19 @@ 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">
<div className="flex flex-col gap-1">
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3> <h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 ${ <div className="flex items-center gap-2 mt-1">
exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700' <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>

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,6 +508,8 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
return; return;
} }
setIsCreating(true);
try {
const newPayments: Payment[] = []; const newPayments: Payment[] = [];
let baseDateStr = formData.dueDate; let baseDateStr = formData.dueDate;
@ -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,18 +141,22 @@ 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]) => {
const numValue = Number(value);
if (numValue > 0 || (value !== '' && numValue === 0)) {
newGradesList.push({ newGradesList.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
studentId: selectedStudent.id, studentId: selectedStudent.id,
subjectId, subjectId,
period: periodId, period: periodId,
value value: numValue,
}); ...(examId !== 'direct' ? { examId } : {})
} as Grade);
} }
}); });
}); });
});
updateData({ grades: newGradesList }); updateData({ grades: newGradesList });
dbService.saveData({ ...data, grades: newGradesList }); dbService.saveData({ ...data, grades: newGradesList });
@ -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';
@ -468,42 +499,97 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500"> <div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500">
MÉDIA: {(() => { MÉDIA: {(() => {
const subjectGrades = studentGrades[subject.id] || {}; const subjectGrades = studentGrades[subject.id] || {};
const vals = Object.values(subjectGrades).filter((v): v is number => typeof v === 'number' && v > 0); const pSums = Object.values(subjectGrades).map((exVals: any) => Object.values(exVals).reduce((a: number, b: any) => a + (b !== '' ? Number(b) : 0), 0));
return vals.length > 0 ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : '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> </div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{periods.map(period => { {periods.map(period => {
// Verificar se há uma prova vinculada a esta disciplina+período const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
const linkedExam = (data.exams || []).find(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 ( return (
<div key={period.id} className="space-y-1.5"> <div key={period.id} className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm space-y-3 relative">
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">{period.name}</label> <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 <input
type="number" type="number"
min="0" min="0"
max="10" max="10"
step="0.1" 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'}`} placeholder="—"
value={studentGrades[subject.id]?.[period.id] || 0} 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) => { onChange={(e) => {
const val = parseFloat(e.target.value) || 0; let val = parseFloat(e.target.value);
if (val > 10) val = 10;
if (val < 0) val = 0;
setStudentGrades(prev => ({ setStudentGrades(prev => ({
...prev, ...prev,
[subject.id]: { [subject.id]: {
...prev[subject.id], ...prev[subject.id],
[period.id]: val [period.id]: {
direct: isNaN(val) ? '' : val
}
} }
})); }));
}} }}
/> />
{linkedExam && ( </div>
<p className="text-[9px] font-bold text-violet-500 truncate ml-1" title={linkedExam.title}>
📝 {linkedExam.title}
</p>
)} )}
</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,65 +125,93 @@ 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>
<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); const subjectGrades = grades.filter(g => g.subjectId === subjectId);
const avg = subjectGrades.length > 0
? subjectGrades.reduce((sum, g) => sum + g.value, 0) / subjectGrades.length
: 0;
return ( return (
<tr key={subjectId} style={{ <div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
animation: `fadeIn 0.3s ease-out ${idx * 0.05}s forwards`, <div style={{ padding: '1.5rem', background: 'var(--color-surface-light)', borderBottom: '1px solid var(--glass-border)' }}>
opacity: 0, <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-primary)' }}>{subjectName}</h2>
</div>
<div style={{ padding: '1.5rem' }}>
{periods.map(period => {
const periodGrades = subjectGrades.filter(g => g.period === period);
if (periodGrades.length === 0) return null;
const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0);
return (
<div key={period} style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem', borderBottom: '2px solid var(--glass-border)', paddingBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-secondary)' }}>{period}</h3>
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--color-text)' }}>
Total do Período: <span style={{ color: getGradeColor(periodTotal, 10) }}>{periodTotal.toFixed(1)}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{periodGrades.map((grade) => {
const isActivity = grade.evaluationType === 'activity';
const maxScore = grade.maxScore ?? 10;
const isDirect = !grade.examId;
return (
<div key={grade.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem',
background: 'var(--color-surface)',
border: '1px solid var(--glass-border)',
borderRadius: '12px'
}}> }}>
<td style={{ fontWeight: 500 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{subjectName} <div style={{
</td> padding: '4px', borderRadius: '6px',
<td style={{ textAlign: 'center' }}> background: isDirect ? 'var(--bg-warning-alpha)' : isActivity ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha)',
{subjectGrades.length > 0 && avg > 0 ? ( color: isDirect ? 'var(--color-warning)' : isActivity ? 'var(--color-info, #0369a1)' : 'var(--color-primary)'
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 48, height: 32, borderRadius: 8, padding: '0 8px',
background: avg >= 7 ? 'var(--bg-success-alpha)' : avg >= 5 ? 'var(--bg-warning-alpha)' : 'var(--bg-danger-alpha)',
fontWeight: 700, fontSize: '0.925rem',
color: getGradeColor(avg),
}}> }}>
{avg.toFixed(1)} <FileText size={16} />
</span> </div>
) : ( <div>
<span style={{ <div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--color-text)' }}>
padding: '4px 10px', {isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
background: 'var(--color-surface-light)', </div>
borderRadius: 6, {!isDirect && (
color: 'var(--color-text-secondary)', <div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)' }}>
fontSize: '0.7rem', {isActivity ? 'ATIVIDADE' : 'PROVA'} VALE: {maxScore} PTS
fontWeight: 600, </div>
textTransform: 'uppercase',
letterSpacing: '0.025em'
}}>
Aguardando
</span>
)} )}
</td> </div>
</tr> </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>
); );
})} })}
</tbody>
</table>
</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>
)} )}
{/* Legend */} {/* Legend */}
@ -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>