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] **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] **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] **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. - [ ] 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) ### 💳 Módulo Financeiro (Portal do Aluno)

View File

@ -1,7 +1,9 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { SchoolData, Exam, Question } from '../types'; 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 { uploadExamImage } from '../services/supabase';
import { useDialog } from '../DialogContext';
import { dbService } from '../services/dbService';
interface ExamsProps { interface ExamsProps {
data: SchoolData; data: SchoolData;
@ -13,6 +15,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const [currentView, setCurrentView] = useState<'list' | 'builder'>('list'); const [currentView, setCurrentView] = useState<'list' | 'builder'>('list');
const [editingExam, setEditingExam] = useState<Exam | null>(null); const [editingExam, setEditingExam] = useState<Exam | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const { showAlert, showConfirm } = useDialog();
const normalizePhotoUrl = (url?: string) => { const normalizePhotoUrl = (url?: string) => {
if (!url) return ''; if (!url) return '';
@ -50,6 +53,30 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
setCurrentView('builder'); 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 = () => { const handleAddQuestion = () => {
if (!editingExam) return; if (!editingExam) return;
setEditingExam({ setEditingExam({
@ -510,12 +537,28 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</p> </p>
)} )}
</div> </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 <button
onClick={() => handleEditExam(exam)} 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" 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> </button>
</div> </div>
</div> </div>

View File

@ -85,10 +85,14 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
const [showDatabaseExplorerModal, setShowDatabaseExplorerModal] = useState(false); const [showDatabaseExplorerModal, setShowDatabaseExplorerModal] = useState(false);
const [dbTables, setDbTables] = useState<any[]>([]); const [dbTables, setDbTables] = useState<any[]>([]);
const [loadingDbTables, setLoadingDbTables] = useState(false); 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 () => { const openDatabaseExplorer = async () => {
setShowDatabaseExplorerModal(true); setShowDatabaseExplorerModal(true);
setLoadingDbTables(true); setLoadingDbTables(true);
setSelectedDbTable(null);
try { try {
const res = await fetch('/api/database/tables'); const res = await fetch('/api/database/tables');
const data = await res.json(); 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) => { const openBucket = async (bucketName: string) => {
setSelectedStorageBucket(bucketName); setSelectedStorageBucket(bucketName);
setLoadingBucket(true); setLoadingBucket(true);
@ -899,18 +916,24 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
{/* Header */} {/* Header */}
<div className="px-8 py-6 border-b border-slate-200/50 flex items-center justify-between bg-white/50"> <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"> <div className="flex items-center gap-4 text-slate-800">
<div className="p-3 bg-blue-100 text-blue-600 rounded-2xl shadow-sm"> {selectedDbTable ? (
<Database size={28} /> <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">
</div> <Database size={24} />
</button>
) : (
<div className="p-3 bg-blue-100 text-blue-600 rounded-2xl shadow-sm">
<Database size={28} />
</div>
)}
<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"> <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> </p>
</div> </div>
</div> </div>
<button <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" 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} /> <X size={24} />
@ -919,34 +942,80 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
{/* Content Body */} {/* Content Body */}
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/30"> <div className="flex-1 overflow-y-auto p-8 bg-slate-50/30">
{loadingDbTables ? ( {!selectedDbTable ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400"> // Lista de Tabelas
<RefreshCw size={48} className="animate-spin mb-4 text-blue-400" /> loadingDbTables ? (
<p className="font-bold">Analisando Estrutura do PostgreSQL...</p> <div className="flex flex-col items-center justify-center h-full text-slate-400">
</div> <RefreshCw size={48} className="animate-spin mb-4 text-blue-400" />
) : dbTables.length === 0 ? ( <p className="font-bold">Analisando Estrutura do PostgreSQL...</p>
<div className="flex flex-col items-center justify-center h-full text-slate-400"> </div>
<Database size={64} className="mb-4 opacity-20" /> ) : dbTables.length === 0 ? (
<p className="font-bold text-xl">Nenhuma tabela encontrada</p> <div className="flex flex-col items-center justify-center h-full text-slate-400">
</div> <Database size={64} className="mb-4 opacity-20" />
) : ( <p className="font-bold text-xl">Nenhuma tabela encontrada</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> </div>
{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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="flex items-center gap-4"> {dbTables.map((table, idx) => (
<div className="p-3 bg-blue-50 text-blue-500 rounded-xl group-hover:bg-blue-500 group-hover:text-white transition-colors"> <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">
<List size={24} /> <div className="flex items-center gap-4">
</div> <div className="p-3 bg-blue-50 text-blue-500 rounded-xl group-hover:bg-blue-500 group-hover:text-white transition-colors">
<div> <List size={24} />
<h4 className="text-sm font-black text-slate-800 uppercase tracking-wide">{table.table_name}</h4> </div>
<div className="flex items-center gap-2 mt-1"> <div>
<span className="text-xs font-bold text-slate-500">{table.row_count} registros</span> <h4 className="text-sm font-black text-slate-800 uppercase tracking-wide">{table.table_name}</h4>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs font-bold text-slate-500">{table.row_count} registros</span>
</div>
</div> </div>
</div> </div>
<span className="text-[10px] font-bold text-slate-400 uppercase bg-slate-100 px-2 py-1 rounded-md">{table.total_size}</span>
</div> </div>
<span className="text-[10px] font-bold text-slate-400 uppercase bg-slate-100 px-2 py-1 rounded-md">{table.total_size}</span> ))}
</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> </div>
@ -956,15 +1025,24 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
{/* Lightbox Preview */} {/* Lightbox Preview */}
{previewUrl && ( {previewUrl && (
<div className="fixed inset-0 z-[60] bg-transparent flex items-center justify-center animate-in fade-in pointer-events-auto"> <div className="fixed inset-0 z-[60] 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]"> {/* Overlay transparente para fechar ao clicar fora */}
<X size={24} /> <div className="absolute inset-0 bg-slate-900/10 cursor-pointer" onClick={() => setPreviewUrl(null)}></div>
</button>
<div className="bg-white p-4 rounded-2xl shadow-[0_0_50px_rgba(0,0,0,0.2)] border border-slate-100"> <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>
{previewUrl.match(/\.(jpeg|jpg|gif|png|webp)$/i) ? ( {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>
</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 // MinIO Explorer
// ============================================================ // ============================================================

View File

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

View File

@ -537,22 +537,27 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
const { examId, answers } = req.body; const { examId, answers } = req.body;
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' }); 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( const { rows: existing } = await pool.query(
'SELECT * FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1', 'SELECT * FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId] [req.user.studentId, examId]
); );
if (existing.length > 0) { 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( await pool.query(
'DELETE FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2', 'DELETE FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2',
[req.user.studentId, examId] [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; const totalQuestions = exam.questions.length;
let correctCount = 0; let correctCount = 0;
for (const q of exam.questions) { for (const q of exam.questions) {