feat: database data viewer, retake policy toggle and exam UI fixes
This commit is contained in:
parent
f4ddee486a
commit
f52c5084c6
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -249,6 +249,9 @@ export interface Exam {
|
|||
durationMinutes: number;
|
||||
status: 'draft' | 'published';
|
||||
questions: Question[];
|
||||
allowRetake?: boolean;
|
||||
evaluationType?: 'exam' | 'activity';
|
||||
maxScore?: number;
|
||||
}
|
||||
|
||||
export interface SchoolData {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue