feat: melhorias no boletim, duplicação de provas e cadeado de retentativa
This commit is contained in:
parent
1d761200f5
commit
402ef4b389
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
12
MEMORY.md
12
MEMORY.md
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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={{
|
||||
width: '100%', padding: '0.65rem',
|
||||
flex: 1, 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,
|
||||
background: 'white', color: 'var(--color-primary)',
|
||||
fontSize: '0.75rem', fontWeight: 700, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
Refazer {(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue