feat: database data viewer, retake policy toggle and exam UI fixes

This commit is contained in:
Sidney 2026-04-29 20:05:00 -03:00
parent f4ddee486a
commit f52c5084c6
6 changed files with 198 additions and 48 deletions

View File

@ -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)

View File

@ -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<ExamsProps> = ({ data, updateData }) => {
const [currentView, setCurrentView] = useState<'list' | 'builder'>('list');
const [editingExam, setEditingExam] = useState<Exam | null>(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<ExamsProps> = ({ 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<ExamsProps> = ({ data, updateData }) => {
</p>
)}
</div>
<div className="border-t border-slate-100 pt-4 flex justify-end">
<div className="border-t border-slate-100 pt-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => 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 ? <Unlock size={18} /> : <Lock 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"
title="Excluir"
>
<Trash2 size={18} />
</button>
</div>
<button
onClick={() => 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'}
</button>
</div>
</div>

View File

@ -85,10 +85,14 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
const [showDatabaseExplorerModal, setShowDatabaseExplorerModal] = useState(false);
const [dbTables, setDbTables] = useState<any[]>([]);
const [loadingDbTables, setLoadingDbTables] = useState(false);
const [selectedDbTable, setSelectedDbTable] = useState<string | null>(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<SettingsProps> = ({ 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<SettingsProps> = ({ data, updateData, setData }) => {
{/* Header */}
<div className="px-8 py-6 border-b border-slate-200/50 flex items-center justify-between bg-white/50">
<div className="flex items-center gap-4 text-slate-800">
{selectedDbTable ? (
<button onClick={() => 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 size={24} />
</button>
) : (
<div className="p-3 bg-blue-100 text-blue-600 rounded-2xl shadow-sm">
<Database size={28} />
</div>
)}
<div>
<h3 className="text-2xl font-black tracking-tight">Database Explorer</h3>
<h3 className="text-2xl font-black tracking-tight">{selectedDbTable ? selectedDbTable : 'Database Explorer'}</h3>
<p className="text-sm font-bold text-slate-500">
{dbTables.length} tabelas no schema public do PostgreSQL.
{selectedDbTable ? `${tableData.rows.length} registros exibidos.` : `${dbTables.length} tabelas no schema public do PostgreSQL.`}
</p>
</div>
</div>
<button
onClick={() => 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"
>
<X size={24} />
@ -919,7 +942,9 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/30">
{loadingDbTables ? (
{!selectedDbTable ? (
// Lista de Tabelas
loadingDbTables ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<RefreshCw size={48} className="animate-spin mb-4 text-blue-400" />
<p className="font-bold">Analisando Estrutura do PostgreSQL...</p>
@ -932,7 +957,7 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{dbTables.map((table, idx) => (
<div key={idx} className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:border-blue-300 hover:shadow-xl transition-all cursor-default flex items-center justify-between hover:-translate-y-1">
<div key={idx} onClick={() => 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">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-50 text-blue-500 rounded-xl group-hover:bg-blue-500 group-hover:text-white transition-colors">
<List size={24} />
@ -948,6 +973,50 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
</div>
))}
</div>
)
) : loadingTableData ? (
// Carregando Dados da Tabela
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<RefreshCw size={48} className="animate-spin mb-4 text-blue-400" />
<p className="font-bold">Buscando registros...</p>
</div>
) : tableData.rows.length === 0 ? (
// Tabela Vazia
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<List size={64} className="mb-4 opacity-20" />
<p className="font-bold text-xl">Tabela Vazia</p>
<p className="text-sm">Nenhum registro encontrado nesta tabela.</p>
</div>
) : (
// Visualização de Dados (Grid)
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-600">
<thead className="text-xs text-slate-700 uppercase bg-slate-50 border-b border-slate-200">
<tr>
{tableData.fields.map((field, idx) => (
<th key={idx} scope="col" className="px-6 py-4 font-black whitespace-nowrap">
{field}
</th>
))}
</tr>
</thead>
<tbody>
{tableData.rows.map((row, rowIdx) => (
<tr key={rowIdx} className="bg-white border-b border-slate-100 hover:bg-slate-50 transition-colors">
{tableData.fields.map((field, colIdx) => (
<td key={colIdx} className="px-6 py-4 whitespace-nowrap">
{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 ? '...' : '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
@ -956,15 +1025,24 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
{/* Lightbox Preview */}
{previewUrl && (
<div className="fixed inset-0 z-[60] bg-transparent flex items-center justify-center animate-in fade-in pointer-events-auto">
<button onClick={() => 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]">
<div className="fixed inset-0 z-[60] flex items-center justify-center animate-in fade-in pointer-events-auto">
{/* Overlay transparente para fechar ao clicar fora */}
<div className="absolute inset-0 bg-slate-900/10 cursor-pointer" onClick={() => setPreviewUrl(null)}></div>
<div className="bg-white p-4 rounded-2xl shadow-[0_0_60px_rgba(0,0,0,0.3)] border border-slate-100 relative z-[61] animate-in zoom-in-95">
{/* Botão de fechar fixado na moldura */}
<button
onClick={() => 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"
>
<X size={24} />
</button>
<div className="bg-white p-4 rounded-2xl shadow-[0_0_50px_rgba(0,0,0,0.2)] border border-slate-100">
{previewUrl.match(/\.(jpeg|jpg|gif|png|webp)$/i) ? (
<img src={previewUrl} alt="Preview" className="max-w-[85vw] max-h-[85vh] object-contain rounded-lg animate-in zoom-in-95" />
<img src={previewUrl} alt="Preview" className="max-w-[85vw] max-h-[85vh] object-contain rounded-lg" />
) : (
<iframe src={previewUrl} className="w-[85vw] h-[85vh] rounded-lg animate-in zoom-in-95" title="PDF Preview" />
<iframe src={previewUrl} className="w-[85vw] h-[85vh] rounded-lg" title="PDF Preview" />
)}
</div>
</div>

View File

@ -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
// ============================================================

View File

@ -249,6 +249,9 @@ export interface Exam {
durationMinutes: number;
status: 'draft' | 'published';
questions: Question[];
allowRetake?: boolean;
evaluationType?: 'exam' | 'activity';
maxScore?: number;
}
export interface SchoolData {

View File

@ -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) {