feat: melhorias no boletim, duplicação de provas e cadeado de retentativa

This commit is contained in:
Sidney 2026-05-01 16:52:17 -03:00
parent 1d761200f5
commit 402ef4b389
7 changed files with 201 additions and 36 deletions

View File

@ -41,8 +41,10 @@
12. **Messages Automation**: Preventive reminders and overdue settings MUST be managed within their respective template modals (contextual logic), with independent manual triggers for each phase (Overdue vs. Upcoming).
13. **Grading & Evaluation Standards**: Assessments are categorized as `exam` (violet/violet labels) or `activity` (sky/blue labels). The report card (Boletim) MUST support multiple evaluations per period, showing the individual breakdown (name and value). The module MUST be named **"Atividades e Provas"** for clarity.
14. **Asaas Safety**: All financial generation forms MUST implement a loading state (`isCreating`) to disable submit buttons and prevent duplicate charges.
15. **Retake Policy**: Students ARE allowed to retake activities and exams. The system MUST delete the previous submission and overwrite the grade in `school_data.json` to ensure only the latest attempt is valid.
15. **Retake Policy**: Students ARE allowed to retake activities and exams. The system MUST implement a "Lock" (Cadeado) logic: if locked, the retake button is hidden; if unlocked, the button is visible and the new submission overwrites the previous grade.
16. **Financial Categories**: The system supports categories: `monthly` (Mensalidade), `registration` (Matrícula), `handout` (Apostila), and `others` (Outros). Automated forms must map references (Cursos -> monthly, Registration -> registration, Handout -> handout).
17. **Modal Floating Principle**: All system modals must avoid backdrop-blur and background overlays. Use `bg-transparent` for the fixed container and `bg-white` (solid) for the modal box, ensuring contrast via large soft shadows (`shadow-2xl` or equivalent).
18. **Automated Messaging (Cron Jobs)**: The system uses `node-cron` for independent message scheduling (Preventive vs. Overdue). Overdue logic MUST implement safety checks using `overdue_warnings_count` and `last_overdue_warning_at` to avoid spamming the student. Immediate webhook triggers for `PAYMENT_OVERDUE` are disabled in favor of scheduled routines.
19. **Numerical Data Integrity**: When retrieving data from PostgreSQL `NUMERIC` or `DECIMAL` columns, values MUST be explicitly cast to `Number()` in the backend before being sent to the frontend to prevent crashes when using `.toFixed()` in React.
20. **Exam Duplication**: The system supports cloning evaluations. Duplicated exams MUST default to `draft` status and include `(Cópia)` in the title to allow verification before deployment to new classes.

View File

@ -5,12 +5,14 @@
> NUNCA execute `git add`, `git commit` ou `git push` sem que o USUÁRIO solicite explicitamente. Alterações devem ser feitas nos arquivos, mas o envio ao repositório remoto é uma ação exclusiva do usuário. Aguarde sempre o comando direto do usuário para realizar qualquer operação de versionamento.
> **ESTA REGRA É INVIOLÁVEL E O ASSISTENTE JÁ FALHOU NELA ANTERIORMENTE. NÃO REPITA O ERRO.**
- [x] **Unificação de Rede (Infra):** Redes unificadas no `docker-compose.yml` (e revertidas para `overlay/internal` conforme preferência do usuário), garantindo conectividade.
- [x] **Unificação de Rede (Infra):** Redes unificadas no `docker-compose.yml`, garantindo conectividade.
- [x] **Correção de Constraints (DB):** Removidas fkeys impeditivas na tabela `provas_submissoes`.
- [x] **Sincronização Automática (JSON -> Tabelas):** Implementada função de espelhamento que popula `alunos` e `provas` a partir do `school_data` no boot do servidor. **VERIFICADO COM SUCESSO.**
- [x] **Espelhamento Total em Tempo Real (Real-time Mirror):** Implementada sincronização instantânea em toda a cadeia de dados (Alunos, Turmas, Provas, Frequência e Períodos). O Postgres agora é um espelho fiel do JSON em milissegundos.
- [x] **Sincronização Acadêmica Portal-Manager:** Notas e submissões agora aparecem corretamente no Boletim Escolar após a resolução do conflito de integridade.
- [!] **Incidente de Workflow (01/05/2026):** O assistente realizou um `git push` não autorizado. A regra foi reforçada e o assistente agora aguarda autorização explícita para cada push.
- [x] **Sincronização Automática (JSON -> Tabelas):** Implementada função de espelhamento total (Alunos, Turmas, Provas, Frequência, Períodos e Notas). **VERIFICADO.**
- [x] **Correção da Média Geral (Boletim):** O sistema agora busca médias reais diretamente do PostgreSQL, eliminando o bug do `0.00` no Manager.
- [x] **Persistência Inteligente de Notas:** Avaliações agora permanecem no Boletim se o aluno já as realizou, independente de estarem em Rascunho ou Publicadas.
- [x] **Cadeado de Retentativa (Portal):** Implementada trava visual e lógica que impede ou permite que alunos refaçam provas no portal conforme configuração do professor.
- [x] **Duplicação de Avaliações:** Nova ferramenta para clonar provas/atividades entre turmas diferentes com um clique.
- [!] **Incidente de Workflow (01/05/2026):** O assistente realizou um `git push` não autorizado. A regra foi reforçada.
- [ ] Próximo Passo: Monitorar o desempenho das consultas nas tabelas relacionais à medida que o volume de submissões aumenta.

View File

@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react';
import { SchoolData, Exam, Question } from '../types';
import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X, RefreshCw, Lock, Unlock, AlertTriangle } from 'lucide-react';
import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X, RefreshCw, Lock, Unlock, AlertTriangle, Copy } from 'lucide-react';
import { uploadExamImage } from '../services/supabase';
import { useDialog } from '../DialogContext';
import { dbService } from '../services/dbService';
@ -15,6 +15,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const [currentView, setCurrentView] = useState<'list' | 'builder'>('list');
const [editingExam, setEditingExam] = useState<Exam | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [duplicatingExam, setDuplicatingExam] = useState<Exam | null>(null);
const [targetClassId, setTargetClassId] = useState('');
const { showAlert, showConfirm } = useDialog();
const normalizePhotoUrl = (url?: string) => {
@ -77,6 +79,26 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
);
};
const handleDuplicateExam = () => {
if (!duplicatingExam || !targetClassId) return;
const newExam: Exam = {
...duplicatingExam,
id: Date.now().toString() + Math.random().toString(36).substring(7),
classId: targetClassId,
status: 'draft', // Sempre começa como rascunho para segurança
title: `${duplicatingExam.title} (Cópia)`
};
const updatedExams = [...exams, newExam];
updateData({ exams: updatedExams });
dbService.saveData({ ...data, exams: updatedExams });
setDuplicatingExam(null);
setTargetClassId('');
showAlert('Sucesso', 'Avaliação duplicada com sucesso!', 'success');
};
const handleAddQuestion = () => {
if (!editingExam) return;
setEditingExam({
@ -570,6 +592,16 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
>
{exam.allowRetake ? <Unlock size={18} /> : <Lock size={18} />}
</button>
<button
onClick={() => {
setDuplicatingExam(exam);
setTargetClassId(exam.classId);
}}
className="p-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-lg transition-colors"
title="Duplicar para outra turma"
>
<Copy size={18} />
</button>
<button
onClick={() => handleDeleteExam(exam.id)}
className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
@ -590,6 +622,55 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
})}
</div>
)}
{/* MODAL DUPLICAR */}
{duplicatingExam && (
<div className="fixed inset-0 bg-transparent z-[60] flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-3xl w-full max-w-md p-8 shadow-2xl animate-slide-up">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
<Copy size={24} />
</div>
<h3 className="text-xl font-black text-slate-800">Duplicar Avaliação</h3>
</div>
<div className="space-y-4">
<p className="text-sm text-slate-500 font-medium">
Escolha a turma que receberá uma cópia de: <br />
<strong className="text-slate-800">{duplicatingExam.title}</strong>
</p>
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2">Turma de Destino</label>
<select
value={targetClassId}
onChange={e => setTargetClassId(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-bold text-slate-700"
>
{data.classes.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={() => setDuplicatingExam(null)}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold hover:bg-slate-200 transition-all"
>
Cancelar
</button>
<button
onClick={handleDuplicateExam}
className="flex-1 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100"
>
Duplicar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -35,11 +35,47 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
const [studentSubmissions, setStudentSubmissions] = useState<Record<string, {acertos: number, erros: number}>>({}); // examId -> { acertos, erros }
const [classGrades, setClassGrades] = useState<Grade[]>([]);
const subjects = data.subjects || [];
const periods = data.periods || [];
const grades = data.grades || [];
// Buscar todas as notas da turma para mostrar médias na lista
React.useEffect(() => {
if (selectedClass) {
const fetchClassGrades = async () => {
try {
const studentIds = data.students.filter(s => s.classId === selectedClass.id).map(s => s.id);
if (studentIds.length === 0) return;
const allGrades: Grade[] = [];
for (const id of studentIds) {
const res = await fetch(`/api/notas/${id}?t=${Date.now()}`);
if (res.ok) {
const json = await res.json();
(json.notas || []).forEach((n: any) => {
allGrades.push({
id: n.id,
studentId: n.aluno_id,
subjectId: n.disciplina_id,
period: n.periodo_id,
value: Number(n.valor)
});
});
}
}
setClassGrades(allGrades);
} catch (e) {
console.error('Erro ao buscar notas da turma:', e);
}
};
fetchClassGrades();
} else {
setClassGrades([]);
}
}, [selectedClass, data.students]);
// Helper para normalizar URLs de fotos (vacina contra cache antigo)
const normalizePhotoUrl = (url?: string) => {
if (!url || typeof url !== 'string') return '';
@ -148,7 +184,11 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
initialGrades[subject.id] = {};
periods.forEach(period => {
const periodGrades: any = {};
const linkedExams = (data.exams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim() && String(e.periodId).trim() === String(period.id).trim() && e.status === 'published');
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim() &&
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
);
if (linkedExams.length > 0) {
linkedExams.forEach(exam => {
@ -239,7 +279,11 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
};
const getStudentGeneralAverage = (studentId: string) => {
const studentGradesList = grades.filter(g => g.studentId === studentId);
// Priorizar notas do Postgres (classGrades) sobre o JSON
const studentGradesList = classGrades.length > 0
? classGrades.filter(g => g.studentId === studentId)
: grades.filter(g => g.studentId === studentId);
if (studentGradesList.length === 0) return '0.00';
const subjectAverages: number[] = [];
@ -530,7 +574,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<div className="space-y-6">
{subjects.map(subject => {
// Encontrar provas vinculadas a esta disciplina
const linkedExams = (data.exams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim() && e.status === 'published');
const linkedExams = (data.exams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim());
return (
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
@ -541,7 +585,10 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</div>
<div className="flex items-center gap-2">
{(() => {
const linkedExams = (data.exams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim() && e.status === 'published');
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
);
const provasCount = linkedExams.filter(e => (e as any).evaluationType !== 'activity').length;
const atividadesCount = linkedExams.filter(e => (e as any).evaluationType === 'activity').length;
return (
@ -573,7 +620,11 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</div>
<div className="flex flex-col gap-6">
{periods.map(period => {
const linkedExams = (data.exams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim() && String(e.periodId).trim() === String(period.id).trim() && e.status === 'published');
const linkedExams = (data.exams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim() &&
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
);
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const periodSum: number = Object.values(periodGrades).reduce<number>((a, b: any) => a + (b !== '' ? Number(b) : 0), 0);

View File

@ -327,7 +327,7 @@ app.get('/api/student-submissions/:studentId', async (req, res) => {
app.get('/api/notas/:alunoId', async (req, res) => {
try {
const { rows: dbNotas } = await pool.query(
'SELECT id, aluno_id as "aluno_id", disciplina_id as "disciplina_id", periodo_id as "periodo_id", prova_id as "prova_id", valor as "valor" FROM notas_boletim WHERE TRIM(aluno_id) = TRIM($1)',
'SELECT id, aluno_id as "aluno_id", disciplina_id as "disciplina_id", periodo_id as "periodo_id", prova_id as "prova_id", valor as "valor" FROM notas WHERE TRIM(aluno_id) = TRIM($1)',
[String(req.params.alunoId).trim()]
);
// Garantir cast numérico para evitar erro de .toFixed no frontend

View File

@ -3,7 +3,7 @@ import { useAuth } from '../context/AuthContext';
import type { Exam, ExamSubmission } from '../types';
import {
ClipboardList, Clock, ChevronLeft, ChevronRight, Send, CheckCircle2,
XCircle, Award, AlertTriangle, Timer, ArrowLeft, RefreshCw
XCircle, Award, AlertTriangle, Timer, ArrowLeft, RefreshCw, Lock, Unlock
} from 'lucide-react';
import { normalizePhotoUrl } from '../helpers';
@ -694,8 +694,12 @@ export default function Avaliacoes() {
color: 'var(--color-text-secondary)',
}}>
<ClipboardList size={56} style={{ opacity: 0.2, marginBottom: '1rem' }} />
<p style={{ fontSize: '1rem', fontWeight: 600 }}>Nenhuma avaliação disponível no momento.</p>
<p style={{ fontSize: '0.8rem', marginTop: 4 }}>As provas aparecerão aqui quando forem publicadas pelo professor.</p>
<p style={{ fontSize: '1rem', fontWeight: 600 }}>
Nenhuma {activeTab === 'activities' ? 'atividade' : 'prova'} disponível no momento.
</p>
<p style={{ fontSize: '0.8rem', marginTop: 4 }}>
As {activeTab === 'activities' ? 'atividades' : 'provas'} aparecerão aqui quando forem publicadas pelo professor.
</p>
</div>
) : (
<div style={{
@ -791,21 +795,43 @@ export default function Avaliacoes() {
</p>
</div>
</div>
<button
onClick={() => {
showAppConfirm('Deseja realmente refazer? Sua nota anterior será substituída.', () => startExam(exam));
}}
style={{
width: '100%', padding: '0.65rem',
borderRadius: 10, border: '1px solid var(--color-primary-alpha)',
background: 'transparent', color: 'var(--color-primary)',
fontSize: '0.8rem', fontWeight: 700,
cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
Refazer {(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
</button>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
{(exam as any).allowRetake ? (
<button
onClick={() => {
showAppConfirm('Deseja realmente refazer? Sua nota anterior será substituída.', () => startExam(exam));
}}
style={{
flex: 1, padding: '0.65rem',
borderRadius: 10, border: '1px solid var(--color-primary-alpha)',
background: 'white', color: 'var(--color-primary)',
fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
<RefreshCw size={14} /> Refazer
</button>
) : (
<div style={{
flex: 1, padding: '0.65rem', borderRadius: 10,
background: 'var(--color-surface-light)', color: 'var(--color-text-secondary)',
fontSize: '0.7rem', fontWeight: 600, textAlign: 'center',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
opacity: 0.6
}}>
<XCircle size={14} /> Bloqueado
</div>
)}
<div style={{
padding: '0.65rem', borderRadius: 10,
background: (exam as any).allowRetake ? 'var(--bg-success-alpha)' : 'var(--bg-danger-alpha)',
color: (exam as any).allowRetake ? 'var(--color-success)' : 'var(--color-danger)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}} title={(exam as any).allowRetake ? 'Retentativa liberada' : 'Retentativa bloqueada'}>
{(exam as any).allowRetake ? <Unlock size={16} /> : <Lock size={16} />}
</div>
</div>
</div>
) : (
<button

View File

@ -176,14 +176,17 @@ export interface ExamQuestion {
export interface Exam {
id: string;
title: string;
description?: string;
classId: string;
courseId?: string;
subjectId?: string;
periodId?: string;
title: string;
durationMinutes: number;
questions: ExamQuestion[];
status: 'draft' | 'published' | 'archived';
createdAt: string;
questions: ExamQuestion[];
allowRetake?: boolean;
evaluationType?: 'exam' | 'activity';
maxScore?: number;
createdAt?: string;
dueDate?: string;
}