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] 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] **Mensagens e Automação Financeira:** Implementado sistema seletivo de disparos (Atrasados vs Preventivos) com botões independentes e lógica de servidor desacoplada.
- [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] **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] 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 Visual: Modais atualizados para `bg-transparent` (sem escurecimento/blur) com `shadow-2xl` para efeito de flutuação premium.
- [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.
- [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] **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] **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] **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.
- [ ] Próximo Passo: Monitorar o desempenho das submissões de provas simultâneas no Portal.
### 💳 Módulo Financeiro (Portal do Aluno)
- **Funcionalidades Implementadas:**
@ -29,6 +27,13 @@
- 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.
### 📝 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)
- **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`.

View File

@ -20,7 +20,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch(e) {}
} catch (e) { }
return url;
};
@ -38,8 +38,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
classId: data.classes[0]?.id || '',
durationMinutes: 60,
status: 'draft',
questions: []
});
questions: [],
evaluationType: 'exam',
maxScore: 10
} as any);
setCurrentView('builder');
};
@ -186,16 +188,38 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<input
type="text"
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"
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>
<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>
<label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label>
<select
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"
>
<option value="" disabled>Selecione uma turma</option>
@ -209,7 +233,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<input
type="number"
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"
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>
<select
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"
>
<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>
<select
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"
>
<option value="">Nenhum (não vincular)</option>
@ -443,10 +467,20 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<div key={exam.id} className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow relative overflow-hidden group">
<div 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">
<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 ${
exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
}`}>
<div className="flex flex-col gap-1">
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
<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'}
</span>
</div>

View File

@ -51,6 +51,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [isFetchingCarne, setIsFetchingCarne] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
React.useEffect(() => {
syncAsaasPayments();
@ -494,6 +495,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
const handleCreatePayment = async (e: React.FormEvent) => {
e.preventDefault();
if (isCreating) return;
if (!formData.studentId || formData.amount <= 0) {
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;
}
const newPayments: Payment[] = [];
setIsCreating(true);
try {
const newPayments: Payment[] = [];
let baseDateStr = formData.dueDate;
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
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');
setIsCreating(false);
return;
}
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');
setIsCreating(false);
return;
}
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');
setIsCreating(false);
return;
}
@ -667,6 +674,9 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
});
showAlert('Sucesso', 'Nova cobrança gerada com sucesso.', 'success');
closeModal();
} finally {
setIsCreating(false);
}
};
const closeModal = () => {
@ -1317,7 +1327,15 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
</div>
<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="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>
</form>
</div>

View File

@ -33,7 +33,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const [searchTerm, setSearchTerm] = useState('');
const [showConfigManager, setShowConfigManager] = useState(false);
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 periods = data.periods || [];
@ -48,7 +48,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch(e) {}
} catch (e) { }
return url;
};
@ -111,13 +111,24 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const handleOpenStudentGrades = (student: Student) => {
setSelectedStudent(student);
const initialGrades: Record<string, Record<string, number>> = {};
const initialGrades: Record<string, Record<string, any>> = {};
subjects.forEach(subject => {
initialGrades[subject.id] = {};
periods.forEach(period => {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id);
initialGrades[subject.id][period.id] = existingGrade ? existingGrade.value : 0;
const periodGrades: any = {};
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
if (linkedExams.length > 0) {
linkedExams.forEach(exam => {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id && (g as any).examId === exam.id);
periodGrades[exam.id] = existingGrade ? existingGrade.value : '';
});
} else {
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id && !(g as any).examId);
periodGrades['direct'] = existingGrade ? existingGrade.value : '';
}
initialGrades[subject.id][period.id] = periodGrades;
});
});
@ -130,16 +141,20 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const newGradesList: Grade[] = [...grades.filter(g => g.studentId !== selectedStudent.id)];
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
Object.entries(periodGrades).forEach(([periodId, value]) => {
if (value > 0) {
newGradesList.push({
id: crypto.randomUUID(),
studentId: selectedStudent.id,
subjectId,
period: periodId,
value
});
}
Object.entries(periodGrades).forEach(([periodId, examValues]) => {
Object.entries(examValues).forEach(([examId, value]) => {
const numValue = Number(value);
if (numValue > 0 || (value !== '' && numValue === 0)) {
newGradesList.push({
id: crypto.randomUUID(),
studentId: selectedStudent.id,
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 totalCount = 0;
Object.values(studentGrades).forEach(subjectPeriods => {
const periodValues = Object.values(subjectPeriods).filter((v): v is number => typeof v === 'number' && v > 0);
if (periodValues.length > 0) {
const subjectSum = periodValues.reduce((a, b) => a + b, 0);
const subjectAvg = subjectSum / periodValues.length;
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
const periodSums: number[] = [];
Object.values(subjectPeriods).forEach((examValues: any) => {
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;
totalCount++;
}
@ -175,8 +197,17 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
subjectsWithGrades.forEach(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';
@ -371,36 +402,36 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
.filter(s => s.classId === selectedClass.id)
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => (
<div
key={student.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
{student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : (
<User size={20} />
)}
<div
key={student.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
{student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : (
<User size={20} />
)}
</div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
</div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
</div>
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
<div className="hidden sm:flex flex-col items-end">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
{getStudentGeneralAverage(student.id)}
</span>
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
<div className="hidden sm:flex flex-col items-end">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
{getStudentGeneralAverage(student.id)}
</span>
</div>
<button
onClick={() => handleOpenStudentGrades(student)}
className="px-3 py-1.5 bg-white text-indigo-600 border border-indigo-100 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-xs flex items-center gap-1.5 shadow-sm"
>
<FileText size={14} /> Notas
</button>
</div>
<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>
@ -452,64 +483,119 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published');
return (
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-indigo-600">
<BookOpen size={18} />
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
<div className="flex items-center gap-2">
{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 key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-indigo-600">
<BookOpen size={18} />
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{periods.map(period => {
// Verificar se há uma prova vinculada a esta disciplina+período
const linkedExam = (data.exams || []).find(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
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="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 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 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>
);
})}

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
subjects: Subject[], // Disciplinas
grades: Grade[], // Notas dos alunos
exams: Exam[], // Avaliações (Provas e Atividades)
profile: SchoolProfile, // Dados da escola (nome, logo, etc.)
logo?: string, // Logo da escola em base64
}
@ -105,6 +106,19 @@ interface Grade {
subjectId: string;
value: number;
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;
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({
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 enrichedGrades = grades.map((g) => {
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))];
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 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
await pool.query(
@ -543,11 +551,18 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
// Integrar com grades no school_data
if (exam.subjectId && exam.periodId) {
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) {
grades[existingGradeIndex].value = finalScore;
} 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;
await saveSchoolData(schoolData);

View File

@ -653,6 +653,24 @@ export default function Avaliacoes() {
)}
<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 }}>
{exam.title}
</h3>
@ -712,7 +730,7 @@ export default function Avaliacoes() {
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>
)}
</div>

View File

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