From 488051673aa402439ad6a236d4ae5afe6a9ca3d7 Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 5 May 2026 21:44:56 -0300 Subject: [PATCH] feat: implement soft delete for exams and update grade calculation to arithmetic mean across manager and portal --- manager/components/Exams.tsx | 126 +++++++++++++++++++----------- manager/components/ReportCard.tsx | 51 ++++++++---- manager/index.tsx | 4 +- manager/server.selfhosted.js | 22 ++++-- manager/services/database.js | 57 +++++++++++++- manager/services/dbService.ts | 10 ++- manager/types.ts | 1 + portal/server.selfhosted.js | 2 +- portal/src/pages/Notas.tsx | 67 ++++++++++++++-- 9 files changed, 256 insertions(+), 84 deletions(-) diff --git a/manager/components/Exams.tsx b/manager/components/Exams.tsx index c219992..fe8fdd3 100644 --- a/manager/components/Exams.tsx +++ b/manager/components/Exams.tsx @@ -17,6 +17,7 @@ const Exams: React.FC = ({ data, updateData }) => { const [isUploading, setIsUploading] = useState(false); const [duplicatingExam, setDuplicatingExam] = useState(null); const [targetClassId, setTargetClassId] = useState(''); + const [activeTab, setActiveTab] = useState<'ativos' | 'lixeira'>('ativos'); const { showAlert, showConfirm } = useDialog(); const normalizePhotoUrl = (url?: string) => { @@ -32,8 +33,9 @@ const Exams: React.FC = ({ data, updateData }) => { const exams = data.exams || []; const filteredExams = exams.filter(exam => - exam.title.toLowerCase().includes(searchTerm.toLowerCase()) || - data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase()) + (activeTab === 'ativos' ? !exam.isDeleted : !!exam.isDeleted) && + (exam.title.toLowerCase().includes(searchTerm.toLowerCase()) || + data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase())) ); const handleStartCreate = () => { @@ -69,17 +71,24 @@ const Exams: React.FC = ({ data, updateData }) => { const handleDeleteExam = (examId: string) => { showConfirm( - 'Excluir Avaliação', - 'Tem certeza que deseja excluir esta avaliação? Esta ação não pode ser desfeita e notas vinculadas no boletim perderão o vínculo.', + 'Mover para Lixeira', + 'Tem certeza que deseja mover esta avaliação para a lixeira? Ela será ocultada para os alunos, mas as notas no boletim continuarão intactas.', () => { - const updatedExams = exams.filter(e => e.id !== examId); + const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: true } : e); updateData({ exams: updatedExams }); dbService.saveData({ ...data, exams: updatedExams }); - showAlert('Sucesso', 'Avaliação excluída com sucesso.', 'success'); + showAlert('Sucesso', 'Avaliação movida para a lixeira.', 'success'); } ); }; + const handleRestoreExam = (examId: string) => { + const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: false } : e); + updateData({ exams: updatedExams }); + dbService.saveData({ ...data, exams: updatedExams }); + showAlert('Sucesso', 'Avaliação reativada.', 'success'); + }; + const handleDuplicateExam = () => { if (!duplicatingExam || !targetClassId) return; @@ -515,6 +524,20 @@ const Exams: React.FC = ({ data, updateData }) => {
+
+ + +
= ({ data, updateData }) => { {filteredExams.map(exam => { const classObj = data.classes.find(c => c.id === exam.classId); return ( -
-
+
+

{exam.title}

@@ -569,9 +592,9 @@ const Exams: React.FC = ({ data, updateData }) => {
- - {exam.status === 'published' ? 'Publicada' : 'Rascunho'} + {exam.isDeleted ? 'Excluída' : (exam.status === 'published' ? 'Publicada' : 'Rascunho')}
@@ -612,45 +635,56 @@ const Exams: React.FC = ({ data, updateData }) => { )}
-
+ {exam.isDeleted ? ( - - - -
- + ) : ( + <> +
+ + + + +
+ + + )}
); diff --git a/manager/components/ReportCard.tsx b/manager/components/ReportCard.tsx index 4810c96..5ba104b 100644 --- a/manager/components/ReportCard.tsx +++ b/manager/components/ReportCard.tsx @@ -259,17 +259,18 @@ const ReportCard: React.FC = ({ data, updateData }) => { let totalCount = 0; Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => { - const periodSums: number[] = []; + const periodAvgs: number[] = []; Object.values(subjectPeriods).forEach((examValues: any) => { - const sum: number = Object.values(examValues).reduce((a, b: any) => a + (b !== '' ? Number(b) : 0), 0); - if (Object.values(examValues).some(v => v !== '')) { - periodSums.push(sum); + const validValues = Object.values(examValues).filter(v => v !== ''); + if (validValues.length > 0) { + const sum: number = validValues.reduce((a, b: any) => a + Number(b), 0); + periodAvgs.push(sum / validValues.length); } }); - if (periodSums.length > 0) { - const subjectAvg = periodSums.reduce((a, b) => a + b, 0) / periodSums.length; + if (periodAvgs.length > 0) { + const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length; totalSum += subjectAvg; totalCount++; } @@ -292,15 +293,23 @@ const ReportCard: React.FC = ({ data, updateData }) => { subjectsWithGrades.forEach(subId => { const subGrades = studentGradesList.filter(g => g.subjectId === subId); - const periodSums: Record = {}; + const periodValues: Record = {}; subGrades.forEach(g => { - periodSums[g.period] = (periodSums[g.period] || 0) + g.value; + if (!periodValues[g.period]) periodValues[g.period] = []; + periodValues[g.period].push(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); + const periodAvgs: number[] = []; + Object.values(periodValues).forEach(values => { + if (values.length > 0) { + const sum = values.reduce((a, b) => a + b, 0); + periodAvgs.push(sum / values.length); + } + }); + + if (periodAvgs.length > 0) { + const totalSum = periodAvgs.reduce((a, b) => a + b, 0); + subjectAverages.push(totalSum / periodAvgs.length); } }); @@ -611,9 +620,14 @@ const ReportCard: React.FC = ({ data, updateData }) => {
MÉDIA: {(() => { const subjectGrades = studentGrades[subject.id] || {}; - const pSums: number[] = Object.values(subjectGrades).map((exVals: any) => Object.values(exVals).reduce((a, 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'; + const pAvgs: number[] = []; + Object.values(subjectGrades).forEach((exVals: any) => { + const validValues = Object.values(exVals).filter(v => v !== ''); + if (validValues.length > 0) { + pAvgs.push(validValues.reduce((a, b: any) => a + Number(b), 0) / validValues.length); + } + }); + return pAvgs.length > 0 ? (pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length).toFixed(1) : '0.0'; })()}
@@ -626,13 +640,16 @@ const ReportCard: React.FC = ({ data, updateData }) => { (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); + const validPeriodValues = Object.values(periodGrades).filter(v => v !== ''); + const periodAvg: number = validPeriodValues.length > 0 + ? validPeriodValues.reduce((a, b: any) => a + Number(b), 0) / validPeriodValues.length + : 0; return (
- Total: {periodSum.toFixed(1)} + Média: {periodAvg.toFixed(1)}
{linkedExams.length > 0 ? ( diff --git a/manager/index.tsx b/manager/index.tsx index 9295149..f1c3134 100644 --- a/manager/index.tsx +++ b/manager/index.tsx @@ -97,7 +97,9 @@ const App = () => { saveTimeoutRef.current = setTimeout(async () => { try { const result = await dbService.saveToCloud(data); - if (result.success) { + if (result.success && result.lastUpdated) { + // Sincroniza o timestamp local com o do servidor para evitar conflitos no polling + setData(prev => ({ ...prev, lastUpdated: result.lastUpdated })); setSyncStatus('saved'); } else if (result.reason === 'newer_version') { setSyncStatus('conflict'); diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 1c2234e..5dd429c 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -202,10 +202,14 @@ app.put('/api/school-data', async (req, res) => { // Sincronização em tempo real (JSON -> Relacional) syncJsonToRelationalTables().catch(err => console.error('[Real-time Sync] Erro:', err.message)); - res.json({ success: true }); + res.json({ + success: true, + message: 'Dados salvos com sucesso', + lastUpdated: schoolData.lastUpdated + }); } catch (error) { - console.error('Erro ao salvar school_data:', error); - res.status(500).json({ success: false, reason: 'error' }); + console.error('Erro ao salvar school-data:', error); + res.status(500).json({ success: false, error: error.message }); } }); @@ -978,13 +982,21 @@ app.post('/api/excluir_cobranca', async (req, res) => { if (!isSinglePayment) { const asaasTargetId = formatInstallmentId(id); const resp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetId}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } }); - if (resp.ok) addLog('Asaas', 'Exclusão Parcelamento OK', { id }); + if (resp.ok) { + addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId }); + // Exclusão imediata no SQL local para evitar que reapareça na UI antes do webhook + await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_installment_id = $1', [asaasTargetId]); + } } else { const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } }); if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); } + + // Exclusão imediata no SQL local + await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1', [id]); + addLog('Asaas', 'Exclusão Cobrança OK', { id }); } - return res.status(200).json({ message: 'Excluído no Asaas (Aguardando Webhook)' }); + return res.status(200).json({ message: 'Excluído no Asaas e na base local' }); } catch (error) { console.error('[Exclusão] Erro:', error); return res.status(500).json({ error: 'Erro interno.' }); diff --git a/manager/services/database.js b/manager/services/database.js index ec7477c..4742c1b 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -351,6 +351,13 @@ export async function syncJsonToRelationalTables() { // 1. Sincronizar Cursos if (data.courses && Array.isArray(data.courses)) { + const courseIds = data.courses.map(c => c.id).filter(Boolean); + if (courseIds.length > 0) { + await client.query('DELETE FROM cursos WHERE id != ALL($1)', [courseIds]); + } else { + await client.query('DELETE FROM cursos'); + } + for (const c of data.courses) { if (!c.id || !c.name) continue; await client.query( @@ -369,8 +376,15 @@ export async function syncJsonToRelationalTables() { // Garantir colunas de refação em provas await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE'); - // 1. Sincronizar Disciplinas (Subjects) + // 2. Sincronizar Disciplinas (Subjects) if (data.subjects && Array.isArray(data.subjects)) { + const subIds = data.subjects.map(s => s.id).filter(Boolean); + if (subIds.length > 0) { + await client.query('DELETE FROM disciplinas WHERE id != ALL($1)', [subIds]); + } else { + await client.query('DELETE FROM disciplinas'); + } + for (const sub of data.subjects) { if (!sub.id || !sub.name) continue; await client.query( @@ -384,6 +398,13 @@ export async function syncJsonToRelationalTables() { // 3. Sincronizar Períodos (Bimestres) if (data.periods && Array.isArray(data.periods)) { + const periodIds = data.periods.map(p => p.id).filter(Boolean); + if (periodIds.length > 0) { + await client.query('DELETE FROM periodos WHERE id != ALL($1)', [periodIds]); + } else { + await client.query('DELETE FROM periodos'); + } + for (const p of data.periods) { if (!p.id || !p.name) continue; await client.query( @@ -397,6 +418,13 @@ export async function syncJsonToRelationalTables() { // 4. Sincronizar Turmas if (data.classes && Array.isArray(data.classes)) { + const classIds = data.classes.map(t => t.id).filter(Boolean); + if (classIds.length > 0) { + await client.query('DELETE FROM turmas WHERE id != ALL($1)', [classIds]); + } else { + await client.query('DELETE FROM turmas'); + } + for (const t of data.classes) { if (!t.id || !t.name) continue; await client.query( @@ -412,8 +440,15 @@ export async function syncJsonToRelationalTables() { } } - // 5. Sincronizar Alunos (Mapeamento Completo de Campos) + // 5. Sincronizar Alunos if (data.students && Array.isArray(data.students)) { + const studentIds = data.students.map(s => s.id).filter(Boolean); + if (studentIds.length > 0) { + await client.query('DELETE FROM alunos WHERE id != ALL($1)', [studentIds]); + } else { + await client.query('DELETE FROM alunos'); + } + for (const s of data.students) { if (!s.id || !s.name) continue; await client.query( @@ -445,8 +480,15 @@ export async function syncJsonToRelationalTables() { } } - // 6. Sincronizar Provas (Corrigindo vínculo com disciplinas) + // 6. Sincronizar Provas if (data.exams && Array.isArray(data.exams)) { + const examIds = data.exams.map(e => e.id).filter(Boolean); + if (examIds.length > 0) { + await client.query('DELETE FROM provas WHERE id != ALL($1)', [examIds]); + } else { + await client.query('DELETE FROM provas'); + } + for (const e of data.exams) { if (!e.id || !e.title) continue; await client.query( @@ -461,8 +503,15 @@ export async function syncJsonToRelationalTables() { } } - // 7. Sincronizar Frequências (Baseado na estrutura real do JSON) + // 7. Sincronizar Frequências if (data.attendance && Array.isArray(data.attendance)) { + const attIds = data.attendance.map(f => f.id).filter(Boolean); + if (attIds.length > 0) { + await client.query('DELETE FROM frequencias WHERE id != ALL($1)', [attIds]); + } else { + await client.query('DELETE FROM frequencias'); + } + for (const f of data.attendance) { if (!f.id || !f.studentId || !f.classId) continue; await client.query( diff --git a/manager/services/dbService.ts b/manager/services/dbService.ts index 09e9ce3..c9fadd6 100644 --- a/manager/services/dbService.ts +++ b/manager/services/dbService.ts @@ -255,7 +255,7 @@ export const dbService = { // Em vez de: supabase.from('school_data').upsert(...) // Agora usa: fetch('/api/school-data', { method: 'PUT' }) // ============================================================ - saveToCloud: async (data: SchoolData): Promise<{ success: boolean; reason?: 'newer_version' | 'error' }> => { + saveToCloud: async (data: SchoolData): Promise<{ success: boolean; reason?: 'newer_version' | 'error'; lastUpdated?: string }> => { try { const response = await fetch('/api/school-data', { method: 'PUT', @@ -263,8 +263,9 @@ export const dbService = { body: JSON.stringify(data), }); + const result = await response.json().catch(() => ({})); + if (!response.ok) { - const result = await response.json().catch(() => ({})); if (response.status === 409 && result.reason === 'newer_version') { console.warn("Servidor tem versão mais nova. Abortando save."); return { success: false, reason: 'newer_version' }; @@ -272,7 +273,10 @@ export const dbService = { throw new Error('Erro ao salvar'); } - return { success: true }; + return { + success: true, + lastUpdated: result.lastUpdated + }; } catch (e) { console.error("Erro ao salvar na nuvem:", e); return { success: false, reason: 'error' }; diff --git a/manager/types.ts b/manager/types.ts index 29e7e84..5f1c6fa 100644 --- a/manager/types.ts +++ b/manager/types.ts @@ -253,6 +253,7 @@ export interface Exam { allowRetake?: boolean; evaluationType?: 'exam' | 'activity'; maxScore?: number; + isDeleted?: boolean; } export interface SchoolData { diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js index abf7a74..cdd66a5 100644 --- a/portal/server.selfhosted.js +++ b/portal/server.selfhosted.js @@ -540,7 +540,7 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => { if (!student) return res.json({ exams: [], submissions: [] }); const exams = (schoolData.exams || []) - .filter(e => e.status === 'published' && e.classId === student.classId) + .filter(e => e.status === 'published' && e.classId === student.classId && !e.isDeleted) .map(e => ({ ...e, questions: e.questions.map(q => ({ diff --git a/portal/src/pages/Notas.tsx b/portal/src/pages/Notas.tsx index 143e784..50e3787 100644 --- a/portal/src/pages/Notas.tsx +++ b/portal/src/pages/Notas.tsx @@ -56,10 +56,43 @@ export default function Notas() { })); // 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 calculateGeneralAverage = () => { + if (displaySubjects.length === 0 || grades.length === 0) return 0; + + let totalSum = 0; + let totalCount = 0; + + displaySubjects.forEach(subject => { + const subjectId = typeof subject === 'string' ? subject : subject.id; + const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId)); + if (subjectGrades.length === 0) return; + + const periodValues: Record = {}; + subjectGrades.forEach(g => { + const periodKey = String(g.periodName || g.period); + if (!periodValues[periodKey]) periodValues[periodKey] = []; + periodValues[periodKey].push(g.value); + }); + + const periodAvgs: number[] = []; + Object.values(periodValues).forEach(values => { + if (values.length > 0) { + const sum = values.reduce((a, b) => a + b, 0); + periodAvgs.push(sum / values.length); + } + }); + + if (periodAvgs.length > 0) { + const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length; + totalSum += subjectAvg; + totalCount++; + } + }); + + return totalCount > 0 ? totalSum / totalCount : 0; + }; + + const totalAvg = calculateGeneralAverage(); const getGradeColor = (value: number, maxScore: number = 10) => { const percentage = (value / maxScore) * 10; @@ -134,10 +167,30 @@ export default function Notas() { const subjectName = typeof subject === 'string' ? subject : subject.name; const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId)); + // Calculate Subject Average + const periodValues: Record = {}; + subjectGrades.forEach(g => { + const periodKey = String(g.periodName || g.period); + if (!periodValues[periodKey]) periodValues[periodKey] = []; + periodValues[periodKey].push(g.value); + }); + const pAvgs: number[] = []; + Object.values(periodValues).forEach(values => { + if (values.length > 0) { + pAvgs.push(values.reduce((a, b) => a + b, 0) / values.length); + } + }); + const subjectAvg = pAvgs.length > 0 ? pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length : null; + return (
-
+

{subjectName}

+ {subjectAvg !== null && ( +
+ MÉDIA: {subjectAvg.toFixed(1)} +
+ )}
@@ -145,14 +198,14 @@ export default function Notas() { const periodGrades = subjectGrades.filter(g => String(g.periodName || g.period) === String(period)); if (periodGrades.length === 0) return null; - const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0); + const periodAvg = periodGrades.length > 0 ? periodGrades.reduce((sum, g) => sum + g.value, 0) / periodGrades.length : 0; return (

{period}

- Total do Período: {periodTotal.toFixed(1)} + Média do Período: {periodAvg.toFixed(1)}