diff --git a/GEMINI.md b/GEMINI.md index 9f52238..d815880 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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. + diff --git a/MEMORY.md b/MEMORY.md index ef6f779..5b0aa43 100644 --- a/MEMORY.md +++ b/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. diff --git a/manager/components/Exams.tsx b/manager/components/Exams.tsx index 988f02b..7ee99e7 100644 --- a/manager/components/Exams.tsx +++ b/manager/components/Exams.tsx @@ -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 = ({ data, updateData }) => { const [currentView, setCurrentView] = useState<'list' | 'builder'>('list'); const [editingExam, setEditingExam] = useState(null); const [isUploading, setIsUploading] = useState(false); + const [duplicatingExam, setDuplicatingExam] = useState(null); + const [targetClassId, setTargetClassId] = useState(''); const { showAlert, showConfirm } = useDialog(); const normalizePhotoUrl = (url?: string) => { @@ -77,6 +79,26 @@ const Exams: React.FC = ({ 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 = ({ data, updateData }) => { > {exam.allowRetake ? : } + + + + + + + )} ); }; diff --git a/manager/components/ReportCard.tsx b/manager/components/ReportCard.tsx index f0c2a28..4810c96 100644 --- a/manager/components/ReportCard.tsx +++ b/manager/components/ReportCard.tsx @@ -35,11 +35,47 @@ const ReportCard: React.FC = ({ data, updateData }) => { const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects'); const [studentGrades, setStudentGrades] = useState>>({}); // subjectId -> periodId -> { examId: value } const [studentSubmissions, setStudentSubmissions] = useState>({}); // examId -> { acertos, erros } + const [classGrades, setClassGrades] = useState([]); 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 = ({ 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 = ({ 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 = ({ data, updateData }) => {
{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 (
@@ -541,7 +585,10 @@ const ReportCard: React.FC = ({ data, updateData }) => {
{(() => { - 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 = ({ data, updateData }) => {
{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((a, b: any) => a + (b !== '' ? Number(b) : 0), 0); diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 88fcc0b..ebbadbc 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -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 diff --git a/portal/src/pages/Avaliacoes.tsx b/portal/src/pages/Avaliacoes.tsx index eaaed85..b031dc6 100644 --- a/portal/src/pages/Avaliacoes.tsx +++ b/portal/src/pages/Avaliacoes.tsx @@ -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)', }}> -

Nenhuma avaliação disponível no momento.

-

As provas aparecerão aqui quando forem publicadas pelo professor.

+

+ Nenhuma {activeTab === 'activities' ? 'atividade' : 'prova'} disponível no momento. +

+

+ As {activeTab === 'activities' ? 'atividades' : 'provas'} aparecerão aqui quando forem publicadas pelo professor. +

) : (
- +
+ {(exam as any).allowRetake ? ( + + ) : ( +
+ Bloqueado +
+ )} + +
+ {(exam as any).allowRetake ? : } +
+
) : (