From f52c5084c6c1ab2d4bce02c05881c92369a530f2 Mon Sep 17 00:00:00 2001
From: Sidney
Date: Wed, 29 Apr 2026 20:05:00 -0300
Subject: [PATCH] feat: database data viewer, retake policy toggle and exam UI
fixes
---
MEMORY.md | 5 +-
manager/components/Exams.tsx | 49 +++++++++-
manager/components/Settings.tsx | 156 ++++++++++++++++++++++++--------
manager/server.selfhosted.js | 18 ++++
manager/types.ts | 3 +
portal/server.selfhosted.js | 15 ++-
6 files changed, 198 insertions(+), 48 deletions(-)
diff --git a/MEMORY.md b/MEMORY.md
index 9177015..515976b 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -19,7 +19,10 @@
- [x] **Sincronia Total:** Integração via `examId` garantindo que notas do portal preencham automaticamente o boletim administrativo.
- [x] **Financeiro Inteligente:** Adicionado suporte ao tipo **"Apostila"** e grupo **"Taxas de Matrícula"** (buscadas dos cards de cursos). Implementado autocompletar inteligente que define o tipo de cobrança baseado na referência selecionada.
- [x] **Storage Explorer (MinIO):** Criada interface de gerenciamento de arquivos que permite navegar por buckets (pastas), visualizar (lightbox), baixar e excluir arquivos físicos individualmente.
-- [x] **Database Explorer (PostgreSQL):** Implementado explorador dinâmico que lista todas as tabelas do banco em tempo real, mostrando contagem de registros e tamanho ocupado.
+- [x] **Database Data Viewer:** Implementada a visualização de registros (linhas) diretamente no Database Explorer, com suporte a redimensionamento automático de colunas e truncamento de dados longos.
+- [x] **Controle de Refação (Retake Policy):** Adicionado botão de cadeado nos cards de Avaliações para permitir ou bloquear que alunos refaçam provas no portal (Regra 15).
+- [x] **UI de Avaliações:** Padronização dos botões de edição ("Editar Prova" vs "Editar Atividade") e adição de botão de exclusão rápida direto no card.
+- [x] **Correção de Vínculo de Notas:** Garantido que o `examId` seja sempre salvo nas notas geradas pelo Portal para preenchimento automático do Boletim Escolar no Manager.
- [ ] Próximo Passo: Iniciar testes de estresse no servidor self-hosted para submissão massiva de fotos de frequência.
### 💳 Módulo Financeiro (Portal do Aluno)
diff --git a/manager/components/Exams.tsx b/manager/components/Exams.tsx
index ef53840..f55cb76 100644
--- a/manager/components/Exams.tsx
+++ b/manager/components/Exams.tsx
@@ -1,7 +1,9 @@
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 } from 'lucide-react';
+import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X, RefreshCw, Lock, Unlock } from 'lucide-react';
import { uploadExamImage } from '../services/supabase';
+import { useDialog } from '../DialogContext';
+import { dbService } from '../services/dbService';
interface ExamsProps {
data: SchoolData;
@@ -13,6 +15,7 @@ const Exams: React.FC = ({ data, updateData }) => {
const [currentView, setCurrentView] = useState<'list' | 'builder'>('list');
const [editingExam, setEditingExam] = useState(null);
const [isUploading, setIsUploading] = useState(false);
+ const { showAlert, showConfirm } = useDialog();
const normalizePhotoUrl = (url?: string) => {
if (!url) return '';
@@ -50,6 +53,30 @@ const Exams: React.FC = ({ data, updateData }) => {
setCurrentView('builder');
};
+ const handleToggleRetake = (examId: string) => {
+ const updatedExams = exams.map(e => {
+ if (e.id === examId) {
+ return { ...e, allowRetake: !e.allowRetake };
+ }
+ return e;
+ });
+ updateData({ exams: updatedExams });
+ dbService.saveData({ ...data, exams: updatedExams });
+ };
+
+ 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.',
+ () => {
+ const updatedExams = exams.filter(e => e.id !== examId);
+ updateData({ exams: updatedExams });
+ dbService.saveData({ ...data, exams: updatedExams });
+ showAlert('Sucesso', 'Avaliação excluída com sucesso.', 'success');
+ }
+ );
+ };
+
const handleAddQuestion = () => {
if (!editingExam) return;
setEditingExam({
@@ -510,12 +537,28 @@ const Exams: React.FC = ({ data, updateData }) => {
)}
-
+
+
+ handleToggleRetake(exam.id)}
+ className={`p-2 rounded-lg transition-colors ${exam.allowRetake ? 'bg-emerald-50 text-emerald-600 hover:bg-emerald-100' : 'bg-slate-50 text-slate-400 hover:bg-slate-100'}`}
+ title={exam.allowRetake ? 'Refação Permitida (Clique para bloquear)' : 'Refação Bloqueada (Clique para permitir)'}
+ >
+ {exam.allowRetake ? : }
+
+ handleDeleteExam(exam.id)}
+ className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
+ title="Excluir"
+ >
+
+
+
handleEditExam(exam)}
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform"
>
- Editar Avaliação
+ Editar {exam.evaluationType === 'activity' ? 'Atividade' : 'Prova'}
diff --git a/manager/components/Settings.tsx b/manager/components/Settings.tsx
index 3d3cfa0..ac859de 100644
--- a/manager/components/Settings.tsx
+++ b/manager/components/Settings.tsx
@@ -85,10 +85,14 @@ const Settings: React.FC = ({ data, updateData, setData }) => {
const [showDatabaseExplorerModal, setShowDatabaseExplorerModal] = useState(false);
const [dbTables, setDbTables] = useState([]);
const [loadingDbTables, setLoadingDbTables] = useState(false);
+ const [selectedDbTable, setSelectedDbTable] = useState(null);
+ const [tableData, setTableData] = useState<{rows: any[], fields: string[]}>({rows: [], fields: []});
+ const [loadingTableData, setLoadingTableData] = useState(false);
const openDatabaseExplorer = async () => {
setShowDatabaseExplorerModal(true);
setLoadingDbTables(true);
+ setSelectedDbTable(null);
try {
const res = await fetch('/api/database/tables');
const data = await res.json();
@@ -101,7 +105,20 @@ const Settings: React.FC = ({ data, updateData, setData }) => {
}
};
-
+ const openTable = async (tableName: string) => {
+ setSelectedDbTable(tableName);
+ setLoadingTableData(true);
+ try {
+ const res = await fetch(`/api/database/tables/${tableName}/data`);
+ const data = await res.json();
+ setTableData({ rows: data.rows || [], fields: data.fields || [] });
+ } catch (e) {
+ console.error(e);
+ showAlert('Erro', 'Não foi possível carregar os dados da tabela.', 'error');
+ } finally {
+ setLoadingTableData(false);
+ }
+ };
const openBucket = async (bucketName: string) => {
setSelectedStorageBucket(bucketName);
setLoadingBucket(true);
@@ -899,18 +916,24 @@ const Settings: React.FC = ({ data, updateData, setData }) => {
{/* Header */}
-
-
-
+ {selectedDbTable ? (
+
setSelectedDbTable(null)} className="p-3 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-2xl shadow-sm transition-all" title="Voltar para Tabelas">
+
+
+ ) : (
+
+
+
+ )}
-
Database Explorer
+
{selectedDbTable ? selectedDbTable : 'Database Explorer'}
- {dbTables.length} tabelas no schema public do PostgreSQL.
+ {selectedDbTable ? `${tableData.rows.length} registros exibidos.` : `${dbTables.length} tabelas no schema public do PostgreSQL.`}
setShowDatabaseExplorerModal(false)}
+ onClick={() => { setShowDatabaseExplorerModal(false); setSelectedDbTable(null); }}
className="p-3 bg-white text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-2xl shadow-sm transition-all"
>
@@ -919,34 +942,80 @@ const Settings: React.FC = ({ data, updateData, setData }) => {
{/* Content Body */}
- {loadingDbTables ? (
-
-
-
Analisando Estrutura do PostgreSQL...
-
- ) : dbTables.length === 0 ? (
-
-
-
Nenhuma tabela encontrada
-
- ) : (
-
- {dbTables.map((table, idx) => (
-
-
-
-
-
-
-
{table.table_name}
-
-
{table.row_count} registros
+ {!selectedDbTable ? (
+ // Lista de Tabelas
+ loadingDbTables ? (
+
+
+
Analisando Estrutura do PostgreSQL...
+
+ ) : dbTables.length === 0 ? (
+
+
+
Nenhuma tabela encontrada
+
+ ) : (
+
+ {dbTables.map((table, idx) => (
+
openTable(table.table_name)} className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 hover:shadow-xl transition-all cursor-pointer flex items-center justify-between hover:-translate-y-1">
+
+
+
+
+
+
{table.table_name}
+
+ {table.row_count} registros
+
+
{table.total_size}
-
{table.total_size}
-
- ))}
+ ))}
+
+ )
+ ) : loadingTableData ? (
+ // Carregando Dados da Tabela
+
+
+
Buscando registros...
+
+ ) : tableData.rows.length === 0 ? (
+ // Tabela Vazia
+
+
+
Tabela Vazia
+
Nenhum registro encontrado nesta tabela.
+
+ ) : (
+ // Visualização de Dados (Grid)
+
+
+
+
+
+ {tableData.fields.map((field, idx) => (
+
+ {field}
+
+ ))}
+
+
+
+ {tableData.rows.map((row, rowIdx) => (
+
+ {tableData.fields.map((field, colIdx) => (
+
+ {typeof row[field] === 'object' && row[field] !== null
+ ? JSON.stringify(row[field]).substring(0, 50) + (JSON.stringify(row[field]).length > 50 ? '...' : '')
+ : String(row[field] ?? '').substring(0, 50) + (String(row[field] ?? '').length > 50 ? '...' : '')}
+
+ ))}
+
+ ))}
+
+
+
)}
@@ -956,15 +1025,24 @@ const Settings: React.FC
= ({ data, updateData, setData }) => {
{/* Lightbox Preview */}
{previewUrl && (
-
-
setPreviewUrl(null)} className="absolute top-6 right-6 p-3 bg-red-50 text-red-500 hover:bg-red-500 hover:text-white shadow-lg rounded-full transition-colors z-[61]">
-
-
-
+
+ {/* Overlay transparente para fechar ao clicar fora */}
+
setPreviewUrl(null)}>
+
+
+ {/* Botão de fechar fixado na moldura */}
+
setPreviewUrl(null)}
+ className="absolute -top-5 -right-5 p-3 bg-red-500 text-white shadow-xl rounded-full hover:bg-red-600 transition-all z-[62] border-4 border-white"
+ title="Fechar Visualização"
+ >
+
+
+
{previewUrl.match(/\.(jpeg|jpg|gif|png|webp)$/i) ? (
-
+
) : (
-
+
)}
diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js
index b4c0a7a..eb319e5 100644
--- a/manager/server.selfhosted.js
+++ b/manager/server.selfhosted.js
@@ -249,6 +249,24 @@ app.get('/api/database/tables', async (req, res) => {
}
});
+app.get('/api/database/tables/:tableName/data', async (req, res) => {
+ try {
+ const { tableName } = req.params;
+
+ // Basic validation to prevent SQL injection on table name
+ if (!/^[a-zA-Z0-9_]+$/.test(tableName)) {
+ return res.status(400).json({ error: 'Nome de tabela inválido' });
+ }
+
+ const query = `SELECT * FROM "${tableName}" LIMIT 100;`;
+ const result = await pool.query(query);
+ res.json({ rows: result.rows, fields: result.fields.map(f => f.name) });
+ } catch (error) {
+ console.error(\`Erro ao buscar dados da tabela \${req.params.tableName}:\`, error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
// ============================================================
// MinIO Explorer
// ============================================================
diff --git a/manager/types.ts b/manager/types.ts
index 1f63bb2..5c82203 100644
--- a/manager/types.ts
+++ b/manager/types.ts
@@ -249,6 +249,9 @@ export interface Exam {
durationMinutes: number;
status: 'draft' | 'published';
questions: Question[];
+ allowRetake?: boolean;
+ evaluationType?: 'exam' | 'activity';
+ maxScore?: number;
}
export interface SchoolData {
diff --git a/portal/server.selfhosted.js b/portal/server.selfhosted.js
index f9be82b..ec6791b 100644
--- a/portal/server.selfhosted.js
+++ b/portal/server.selfhosted.js
@@ -537,22 +537,27 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
const { examId, answers } = req.body;
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
- // Verificar se já submeteu e deletar a submissão anterior para permitir refazer
+ const schoolData = await getSchoolData();
+ const exam = (schoolData.exams || []).find(e => e.id === examId);
+ if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' });
+
+ // Verificar se já submeteu
const { rows: existing } = await pool.query(
'SELECT * FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId]
);
+
if (existing.length > 0) {
+ if (!exam.allowRetake) {
+ return res.status(409).json({ error: 'Você já realizou esta avaliação e ela não permite refação.' });
+ }
+ // Se permite refazer, deleta a anterior
await pool.query(
'DELETE FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2',
[req.user.studentId, examId]
);
}
- const schoolData = await getSchoolData();
- const exam = (schoolData.exams || []).find(e => e.id === examId);
- if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' });
-
const totalQuestions = exam.questions.length;
let correctCount = 0;
for (const q of exam.questions) {