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');
}; };
@ -98,7 +100,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
if (!editingExam) return; if (!editingExam) return;
const newQuestions = [...editingExam.questions]; const newQuestions = [...editingExam.questions];
newQuestions[qIndex].options.splice(oIndex, 1); newQuestions[qIndex].options.splice(oIndex, 1);
// Adjust correctOptionIndex if needed // Adjust correctOptionIndex if needed
if (newQuestions[qIndex].correctOptionIndex >= newQuestions[qIndex].options.length) { if (newQuestions[qIndex].correctOptionIndex >= newQuestions[qIndex].options.length) {
newQuestions[qIndex].correctOptionIndex = Math.max(0, newQuestions[qIndex].options.length - 1); newQuestions[qIndex].correctOptionIndex = Math.max(0, newQuestions[qIndex].options.length - 1);
@ -111,7 +113,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const handleImageUpload = async (qIndex: number, event: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (qIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setIsUploading(true); setIsUploading(true);
try { try {
const url = await uploadExamImage(file); const url = await uploadExamImage(file);
@ -134,7 +136,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const handleSave = (status: 'draft' | 'published') => { const handleSave = (status: 'draft' | 'published') => {
if (!editingExam) return; if (!editingExam) return;
if (!editingExam.title || !editingExam.classId) { if (!editingExam.title || !editingExam.classId) {
alert('Preencha o título e a turma antes de salvar.'); alert('Preencha o título e a turma antes de salvar.');
return; return;
@ -143,7 +145,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const finalExam = { ...editingExam, status }; const finalExam = { ...editingExam, status };
const currentExams = data.exams || []; const currentExams = data.exams || [];
const existingIndex = currentExams.findIndex(e => e.id === finalExam.id); const existingIndex = currentExams.findIndex(e => e.id === finalExam.id);
let newExams; let newExams;
if (existingIndex >= 0) { if (existingIndex >= 0) {
newExams = [...currentExams]; newExams = [...currentExams];
@ -151,7 +153,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
} else { } else {
newExams = [...currentExams, finalExam]; newExams = [...currentExams, finalExam];
} }
updateData({ exams: newExams }); updateData({ exams: newExams });
setCurrentView('list'); setCurrentView('list');
setEditingExam(null); setEditingExam(null);
@ -161,7 +163,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
return ( return (
<div className="p-8 max-w-4xl mx-auto animate-in fade-in duration-500 pb-32"> <div className="p-8 max-w-4xl mx-auto animate-in fade-in duration-500 pb-32">
<div className="flex items-center gap-4 mb-8"> <div className="flex items-center gap-4 mb-8">
<button <button
onClick={() => setCurrentView('list')} onClick={() => setCurrentView('list')}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
> >
@ -183,19 +185,41 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-bold text-slate-700 mb-2">Título da Avaliação</label> <label className="block text-sm font-bold text-slate-700 mb-2">Título da Avaliação</label>
<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>
@ -206,19 +230,19 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div> </div>
<div> <div>
<label className="block text-sm font-bold text-slate-700 mb-2">Duração (Minutos)</label> <label className="block text-sm font-bold text-slate-700 mb-2">Duração (Minutos)</label>
<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"
/> />
</div> </div>
<div> <div>
<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>
@ -230,9 +254,9 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div> </div>
<div> <div>
<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>
@ -250,13 +274,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
{editingExam.questions.map((question, qIndex) => ( {editingExam.questions.map((question, qIndex) => (
<div key={question.id} className="bg-white rounded-2xl p-8 shadow-md border border-slate-200 relative group animate-slide-up"> <div key={question.id} className="bg-white rounded-2xl p-8 shadow-md border border-slate-200 relative group animate-slide-up">
<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-center mb-6"> <div className="flex justify-between items-center mb-6">
<h4 className="text-lg font-black text-slate-800 flex items-center gap-2"> <h4 className="text-lg font-black text-slate-800 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm">{qIndex + 1}</span> <span className="w-8 h-8 rounded-lg bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm">{qIndex + 1}</span>
Questão Questão
</h4> </h4>
<button <button
onClick={() => handleRemoveQuestion(qIndex)} onClick={() => handleRemoveQuestion(qIndex)}
className="text-slate-400 hover:text-red-500 transition-colors p-2" className="text-slate-400 hover:text-red-500 transition-colors p-2"
title="Remover Questão" title="Remover Questão"
@ -269,7 +293,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
{/* Enunciado */} {/* Enunciado */}
<div> <div>
<label className="block text-sm font-bold text-slate-700 mb-2">Enunciado</label> <label className="block text-sm font-bold text-slate-700 mb-2">Enunciado</label>
<textarea <textarea
value={question.text} value={question.text}
onChange={e => handleQuestionChange(qIndex, 'text', e.target.value)} onChange={e => handleQuestionChange(qIndex, 'text', e.target.value)}
placeholder="Digite o enunciado da questão..." placeholder="Digite o enunciado da questão..."
@ -283,7 +307,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
{question.imageUrl ? ( {question.imageUrl ? (
<div className="relative inline-block mt-2 group/img"> <div className="relative inline-block mt-2 group/img">
<img src={normalizePhotoUrl(question.imageUrl)} alt="Apoio" className="max-w-full md:max-w-md h-auto rounded-xl border border-slate-200 shadow-sm" /> <img src={normalizePhotoUrl(question.imageUrl)} alt="Apoio" className="max-w-full md:max-w-md h-auto rounded-xl border border-slate-200 shadow-sm" />
<button <button
onClick={() => handleQuestionChange(qIndex, 'imageUrl', undefined)} onClick={() => handleQuestionChange(qIndex, 'imageUrl', undefined)}
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-lg opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600 shadow-lg" className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-lg opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600 shadow-lg"
> >
@ -292,10 +316,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div> </div>
) : ( ) : (
<label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors"> <label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
className="hidden" className="hidden"
onChange={(e) => handleImageUpload(qIndex, e)} onChange={(e) => handleImageUpload(qIndex, e)}
disabled={isUploading} disabled={isUploading}
/> />
@ -314,13 +338,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
{/* Alternativas */} {/* Alternativas */}
<div className="pt-4 border-t border-slate-100"> <div className="pt-4 border-t border-slate-100">
<label className="block text-sm font-bold text-slate-700 mb-4">Alternativas</label> <label className="block text-sm font-bold text-slate-700 mb-4">Alternativas</label>
<div className="space-y-3"> <div className="space-y-3">
{question.options.map((option, oIndex) => ( {question.options.map((option, oIndex) => (
<div key={oIndex} className={`flex items-center gap-3 p-2 rounded-xl transition-colors ${question.correctOptionIndex === oIndex ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50 border border-transparent'}`}> <div key={oIndex} className={`flex items-center gap-3 p-2 rounded-xl transition-colors ${question.correctOptionIndex === oIndex ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50 border border-transparent'}`}>
<div className="flex items-center justify-center w-10"> <div className="flex items-center justify-center w-10">
<input <input
type="radio" type="radio"
name={`correct-${question.id}`} name={`correct-${question.id}`}
checked={question.correctOptionIndex === oIndex} checked={question.correctOptionIndex === oIndex}
onChange={() => handleQuestionChange(qIndex, 'correctOptionIndex', oIndex)} onChange={() => handleQuestionChange(qIndex, 'correctOptionIndex', oIndex)}
@ -328,14 +352,14 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
title="Marcar como correta" title="Marcar como correta"
/> />
</div> </div>
<input <input
type="text" type="text"
value={option} value={option}
onChange={e => handleOptionChange(qIndex, oIndex, e.target.value)} onChange={e => handleOptionChange(qIndex, oIndex, e.target.value)}
placeholder={`Alternativa ${String.fromCharCode(65 + oIndex)}`} placeholder={`Alternativa ${String.fromCharCode(65 + oIndex)}`}
className="flex-1 bg-transparent border-none focus:ring-0 p-2 font-medium text-slate-800 placeholder:text-slate-400" className="flex-1 bg-transparent border-none focus:ring-0 p-2 font-medium text-slate-800 placeholder:text-slate-400"
/> />
<button <button
onClick={() => handleRemoveOption(qIndex, oIndex)} onClick={() => handleRemoveOption(qIndex, oIndex)}
className="p-2 text-slate-400 hover:text-red-500 transition-colors" className="p-2 text-slate-400 hover:text-red-500 transition-colors"
disabled={question.options.length <= 2} disabled={question.options.length <= 2}
@ -347,7 +371,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
))} ))}
</div> </div>
<button <button
onClick={() => handleAddOption(qIndex)} onClick={() => handleAddOption(qIndex)}
className="mt-4 flex items-center gap-2 text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors" className="mt-4 flex items-center gap-2 text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
> >
@ -359,7 +383,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
))} ))}
{/* Botão Adicionar Questão */} {/* Botão Adicionar Questão */}
<button <button
onClick={handleAddQuestion} onClick={handleAddQuestion}
className="w-full flex flex-col items-center justify-center gap-3 p-8 border-2 border-dashed border-indigo-200 rounded-2xl text-indigo-600 hover:bg-indigo-50 hover:border-indigo-400 transition-all font-bold group" className="w-full flex flex-col items-center justify-center gap-3 p-8 border-2 border-dashed border-indigo-200 rounded-2xl text-indigo-600 hover:bg-indigo-50 hover:border-indigo-400 transition-all font-bold group"
> >
@ -372,13 +396,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
{/* Sticky Actions Bar */} {/* Sticky Actions Bar */}
<div className="fixed bottom-0 left-0 md:left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-end gap-4 shadow-[0_-10px_40px_-15px_rgba(0,0,0,0.1)] z-40"> <div className="fixed bottom-0 left-0 md:left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-end gap-4 shadow-[0_-10px_40px_-15px_rgba(0,0,0,0.1)] z-40">
<button <button
onClick={() => handleSave('draft')} onClick={() => handleSave('draft')}
className="flex items-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition-colors" className="flex items-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition-colors"
> >
<Save size={20} /> Salvar como Rascunho <Save size={20} /> Salvar como Rascunho
</button> </button>
<button <button
onClick={() => handleSave('published')} onClick={() => handleSave('published')}
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all" className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all"
> >
@ -399,19 +423,19 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</h2> </h2>
<p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p> <p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p>
</div> </div>
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
<div className="relative"> <div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input <input
type="text" type="text"
placeholder="Buscar avaliação..." placeholder="Buscar avaliação..."
className="pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm w-full md:w-64 transition-all" className="pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm w-full md:w-64 transition-all"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
<button <button
onClick={handleStartCreate} onClick={handleStartCreate}
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-200" className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-200"
> >
@ -428,7 +452,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div> </div>
<h3 className="text-xl font-bold text-slate-700 mb-2">Nenhuma avaliação encontrada</h3> <h3 className="text-xl font-bold text-slate-700 mb-2">Nenhuma avaliação encontrada</h3>
<p className="text-slate-500 mb-6 max-w-md">Você ainda não criou nenhuma prova ou os filtros não retornaram resultados.</p> <p className="text-slate-500 mb-6 max-w-md">Você ainda não criou nenhuma prova ou os filtros não retornaram resultados.</p>
<button <button
onClick={handleStartCreate} onClick={handleStartCreate}
className="px-6 py-3 bg-indigo-50 text-indigo-700 rounded-xl font-bold hover:bg-indigo-100 transition-colors" className="px-6 py-3 bg-indigo-50 text-indigo-700 rounded-xl font-bold hover:bg-indigo-100 transition-colors"
> >
@ -443,41 +467,51 @@ 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>
<div className="space-y-2 mb-6"> <div className="space-y-2 mb-6">
<p className="text-sm text-slate-500 flex items-center gap-2"> <p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Turma:</span> <span className="font-bold text-slate-700">Turma:</span>
{classObj?.name || 'Turma não encontrada'} {classObj?.name || 'Turma não encontrada'}
</p> </p>
<p className="text-sm text-slate-500 flex items-center gap-2"> <p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Questões:</span> <span className="font-bold text-slate-700">Questões:</span>
{exam.questions?.length || 0} {exam.questions?.length || 0}
</p> </p>
<p className="text-sm text-slate-500 flex items-center gap-2"> <p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Duração:</span> <span className="font-bold text-slate-700">Duração:</span>
{exam.durationMinutes} min {exam.durationMinutes} min
</p> </p>
{exam.subjectId && ( {exam.subjectId && (
<p className="text-sm text-slate-500 flex items-center gap-2"> <p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Disciplina:</span> <span className="font-bold text-slate-700">Disciplina:</span>
{(data.subjects || []).find(s => s.id === exam.subjectId)?.name || '—'} {(data.subjects || []).find(s => s.id === exam.subjectId)?.name || '—'}
</p> </p>
)} )}
{exam.periodId && ( {exam.periodId && (
<p className="text-sm text-slate-500 flex items-center gap-2"> <p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Período:</span> <span className="font-bold text-slate-700">Período:</span>
{(data.periods || []).find(p => p.id === exam.periodId)?.name || '—'} {(data.periods || []).find(p => p.id === exam.periodId)?.name || '—'}
</p> </p>
)} )}
</div> </div>
<div className="border-t border-slate-100 pt-4 flex justify-end"> <div className="border-t border-slate-100 pt-4 flex justify-end">
<button <button
onClick={() => handleEditExam(exam)} onClick={() => handleEditExam(exam)}
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform" className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform"
> >

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

@ -2,15 +2,15 @@ import React, { useState } from 'react';
import { SchoolData, Class, Student, Subject, Grade, Period } from '../types'; import { SchoolData, Class, Student, Subject, Grade, Period } from '../types';
import { dbService } from '../services/dbService'; import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext'; import { useDialog } from '../DialogContext';
import { import {
FileText, FileText,
Plus, Plus,
Trash2, Trash2,
ChevronRight, ChevronRight,
Save, Save,
GraduationCap, GraduationCap,
BookOpen, BookOpen,
User, User,
X, X,
Search, Search,
CheckCircle2, CheckCircle2,
@ -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 || [];
@ -44,12 +44,12 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
if (!url || typeof url !== 'string') return ''; if (!url || typeof url !== 'string') return '';
if (url.startsWith('data:image') || url.startsWith('blob:')) return url; if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
if (url.startsWith('/storage/')) return url; if (url.startsWith('/storage/')) return url;
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;
}; };
@ -85,7 +85,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const handleDeleteSubject = (id: string) => { const handleDeleteSubject = (id: string) => {
showConfirm( showConfirm(
'Excluir Disciplina', 'Excluir Disciplina',
'⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.', '⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.',
() => { () => {
const updatedSubjects = subjects.filter(s => s.id !== id); const updatedSubjects = subjects.filter(s => s.id !== id);
@ -98,7 +98,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const handleDeletePeriod = (id: string) => { const handleDeletePeriod = (id: string) => {
showConfirm( showConfirm(
'Excluir Período', 'Excluir Período',
'⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.', '⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.',
() => { () => {
const updatedPeriods = periods.filter(p => p.id !== id); const updatedPeriods = periods.filter(p => p.id !== id);
@ -111,16 +111,27 @@ 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;
}); });
}); });
setStudentGrades(initialGrades); setStudentGrades(initialGrades);
}; };
@ -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';
@ -184,7 +215,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
return (totalSum / subjectAverages.length).toFixed(2); return (totalSum / subjectAverages.length).toFixed(2);
}; };
const filteredClasses = data.classes.filter(c => const filteredClasses = data.classes.filter(c =>
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) (c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
); );
@ -195,7 +226,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2> <h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2>
<p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p> <p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p>
</div> </div>
<button <button
onClick={() => setShowConfigManager(!showConfigManager)} onClick={() => setShowConfigManager(!showConfigManager)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2" className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
> >
@ -213,13 +244,13 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3> <h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3>
</div> </div>
<div className="flex bg-slate-100 p-1 rounded-xl"> <div className="flex bg-slate-100 p-1 rounded-xl">
<button <button
onClick={() => setConfigTab('subjects')} onClick={() => setConfigTab('subjects')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`} className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
> >
DISCIPLINAS DISCIPLINAS
</button> </button>
<button <button
onClick={() => setConfigTab('periods')} onClick={() => setConfigTab('periods')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`} className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
> >
@ -231,14 +262,14 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
{configTab === 'subjects' ? ( {configTab === 'subjects' ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
placeholder="Nome da disciplina (ex: Matemática, Inglês...)" placeholder="Nome da disciplina (ex: Matemática, Inglês...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm" className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newSubjectName} value={newSubjectName}
onChange={(e) => setNewSubjectName(e.target.value)} onChange={(e) => setNewSubjectName(e.target.value)}
/> />
<button <button
onClick={handleAddSubject} onClick={handleAddSubject}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2" className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
> >
@ -250,7 +281,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
{subjects.map(subject => ( {subjects.map(subject => (
<div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group"> <div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{subject.name}</span> <span className="font-bold text-slate-700">{subject.name}</span>
<button <button
onClick={() => handleDeleteSubject(subject.id)} onClick={() => handleDeleteSubject(subject.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100" className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
> >
@ -266,14 +297,14 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)" placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm" className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newPeriodName} value={newPeriodName}
onChange={(e) => setNewPeriodName(e.target.value)} onChange={(e) => setNewPeriodName(e.target.value)}
/> />
<button <button
onClick={handleAddPeriod} onClick={handleAddPeriod}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2" className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
> >
@ -285,7 +316,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
{periods.map(period => ( {periods.map(period => (
<div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group"> <div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{period.name}</span> <span className="font-bold text-slate-700">{period.name}</span>
<button <button
onClick={() => handleDeletePeriod(period.id)} onClick={() => handleDeletePeriod(period.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100" className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
> >
@ -306,9 +337,9 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<> <>
<div className="relative max-w-md"> <div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input <input
type="text" type="text"
placeholder="Buscar turmas..." placeholder="Buscar turmas..."
className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm" className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
@ -320,8 +351,8 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const course = data.courses.find(c => c.id === cls.courseId); const course = data.courses.find(c => c.id === cls.courseId);
const studentCount = data.students.filter(s => s.classId === cls.id).length; const studentCount = data.students.filter(s => s.classId === cls.id).length;
return ( return (
<div <div
key={cls.id} key={cls.id}
onClick={() => setSelectedClass(cls)} onClick={() => setSelectedClass(cls)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden" className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden"
> >
@ -348,7 +379,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</> </>
) : ( ) : (
<div className="space-y-6 animate-in slide-in-from-left-4"> <div className="space-y-6 animate-in slide-in-from-left-4">
<button <button
onClick={() => setSelectedClass(null)} onClick={() => setSelectedClass(null)}
className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors" className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors"
> >
@ -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>
@ -422,7 +453,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar {selectedClass?.name}</p> <p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar {selectedClass?.name}</p>
</div> </div>
</div> </div>
<button <button
onClick={() => setSelectedStudent(null)} onClick={() => setSelectedStudent(null)}
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all" className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"
> >
@ -435,10 +466,10 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<div className="text-center py-12 space-y-4"> <div className="text-center py-12 space-y-4">
<AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" /> <AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" />
<p className="text-slate-500 font-medium"> <p className="text-slate-500 font-medium">
{subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'} {subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'}
Por favor, complete as configurações primeiro. Por favor, complete as configurações primeiro.
</p> </p>
<button <button
onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }} onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm" className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm"
> >
@ -450,66 +481,121 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
{subjects.map(subject => { {subjects.map(subject => {
// Encontrar provas vinculadas a esta disciplina // Encontrar provas vinculadas a esta disciplina
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>
); );
})} })}
@ -533,13 +619,13 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</div> </div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3"> <div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button <button
onClick={() => setSelectedStudent(null)} onClick={() => setSelectedStudent(null)}
className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all" className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all"
> >
Cancelar Cancelar
</button> </button>
<button <button
onClick={handleSaveGrades} onClick={handleSaveGrades}
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2" className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2"
> >

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');
@ -308,7 +315,7 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
} }
notifications.push({ notifications.push({
id: `notif-${Date.now()}`, id: `notif-${Date.now()}`,
studentId: 'admin', studentId: 'admin',
fromStudentId: req.user.studentId, // Identificador para navegação no Manager fromStudentId: req.user.studentId, // Identificador para navegação no Manager
title: 'Nova Justificativa de Falta', title: 'Nova Justificativa de Falta',
@ -316,8 +323,8 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`, text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
motivo: motivo.trim() motivo: motivo.trim()
}), }),
attachment: publicUrl, attachment: publicUrl,
read: false, read: false,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
@ -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> return (
</tr> <div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
</thead> <div style={{ padding: '1.5rem', background: 'var(--color-surface-light)', borderBottom: '1px solid var(--glass-border)' }}>
<tbody> <h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-primary)' }}>{subjectName}</h2>
{displaySubjects.map((s, idx) => { </div>
const subjectId = typeof s === 'string' ? s : s.id;
const subjectName = typeof s === 'string' ? s : s.name; <div style={{ padding: '1.5rem' }}>
{periods.map(period => {
const periodGrades = subjectGrades.filter(g => g.period === period);
if (periodGrades.length === 0) return null;
const subjectGrades = grades.filter(g => g.subjectId === subjectId); const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0);
const avg = subjectGrades.length > 0
? subjectGrades.reduce((sum, g) => sum + g.value, 0) / subjectGrades.length return (
: 0; <div key={period} style={{ marginBottom: '1.5rem' }}>
return ( <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem', borderBottom: '2px solid var(--glass-border)', paddingBottom: '0.5rem' }}>
<tr key={subjectId} style={{ <h3 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-secondary)' }}>{period}</h3>
animation: `fadeIn 0.3s ease-out ${idx * 0.05}s forwards`, <div style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--color-text)' }}>
opacity: 0, Total do Período: <span style={{ color: getGradeColor(periodTotal, 10) }}>{periodTotal.toFixed(1)}</span>
}}> </div>
<td style={{ fontWeight: 500 }}> </div>
{subjectName}
</td> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<td style={{ textAlign: 'center' }}> {periodGrades.map((grade) => {
{subjectGrades.length > 0 && avg > 0 ? ( const isActivity = grade.evaluationType === 'activity';
<span style={{ const maxScore = grade.maxScore ?? 10;
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', const isDirect = !grade.examId;
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)', return (
fontWeight: 700, fontSize: '0.925rem', <div key={grade.id} style={{
color: getGradeColor(avg), display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}> padding: '0.75rem 1rem',
{avg.toFixed(1)} background: 'var(--color-surface)',
</span> border: '1px solid var(--glass-border)',
) : ( borderRadius: '12px'
<span style={{ }}>
padding: '4px 10px', <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
background: 'var(--color-surface-light)', <div style={{
borderRadius: 6, padding: '4px', borderRadius: '6px',
color: 'var(--color-text-secondary)', background: isDirect ? 'var(--bg-warning-alpha)' : isActivity ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha)',
fontSize: '0.7rem', color: isDirect ? 'var(--color-warning)' : isActivity ? 'var(--color-info, #0369a1)' : 'var(--color-primary)'
fontWeight: 600, }}>
textTransform: 'uppercase', <FileText size={16} />
letterSpacing: '0.025em' </div>
}}> <div>
Aguardando <div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--color-text)' }}>
</span> {isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
)} </div>
</td> {!isDirect && (
</tr> <div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)' }}>
); {isActivity ? 'ATIVIDADE' : 'PROVA'} VALE: {maxScore} PTS
})} </div>
</tbody> )}
</table> </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>