feat: exploradores de storage e database, melhorias financeiras e padronização de modais

This commit is contained in:
Sidney 2026-04-29 19:16:59 -03:00
parent bf4ebd8b6b
commit f4ddee486a
13 changed files with 479 additions and 45 deletions

View File

@ -35,3 +35,5 @@
13. **Grading & Evaluation Standards**: Assessments are categorized as `exam` (violet/violet labels) or `activity` (sky/blue labels). The report card (Boletim) MUST support multiple evaluations per period, showing the individual breakdown (name and value). The module MUST be named **"Atividades e Provas"** for clarity.
14. **Asaas Safety**: All financial generation forms MUST implement a loading state (`isCreating`) to disable submit buttons and prevent duplicate charges.
15. **Retake Policy**: Students ARE allowed to retake activities and exams. The system MUST delete the previous submission and overwrite the grade in `school_data.json` to ensure only the latest attempt is valid.
16. **Financial Categories**: The system supports categories: `monthly` (Mensalidade), `registration` (Matrícula), `handout` (Apostila), and `others` (Outros). Automated forms must map references (Cursos -> monthly, Registration -> registration, Handout -> handout).
17. **Modal Floating Principle**: All system modals must avoid backdrop-blur and background overlays. Use `bg-transparent` for the fixed container and `bg-white` (solid) for the modal box, ensuring contrast via large soft shadows (`shadow-2xl` or equivalent).

View File

@ -13,11 +13,14 @@
- [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU.
- [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino.
- [x] **Segurança Financeira:** Implementada trava de segurança (`isCreating`) contra cliques múltiplos em formulários financeiros, resolvendo a duplicidade de cobranças no Asaas.
- [x] **Boletim Detalhado (Manager):** Upgrade para layout de lista (Full-Width) com cores distintas: **Violeta (Provas)** e **Azul (Atividades)**.
- [x] **Retake Logic:** Implementada possibilidade de refazer Provas/Atividades no Portal, com substituição automática da nota anterior e limpeza de submissão no banco.
- [x] **Mapeamento de Períodos:** Corrigido o bug que exibia UUIDs (códigos) no boletim do aluno; agora exibe os nomes amigáveis (ex: 1º Bimestre).
- [x] **Nomenclatura Unificada:** Alterado "Avaliações" para **"Atividades e Provas"** em todo o ecossistema (Portal e Manager).
- [ ] Próximo Passo: Analisar a necessidade de pesos diferenciados (médias ponderadas) entre Atividades e Provas no cálculo do boletim.
- [x] **Boletim Detalhado (Manager):** Refatoração para suportar N avaliações por bimestre, com interface que diferencia Provas de Atividades.
- [x] **Ambiente de Provas (Portal):** Implementado modo imersivo com cronômetro pulsante (alerta vermelho < 1min) e etiquetas dinâmicas por tipo de avaliação.
- [x] **Boletim Analítico (Portal):** Nova interface de notas que mostra o extrato completo de cada avaliação realizada, separada por matéria e bimestre.
- [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.
- [ ] 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)
- **Funcionalidades Implementadas:**
@ -54,6 +57,7 @@
- [x] **Frequência e Biometria (AttendanceQuery):** Corrigido bug de contagem, deduplicação de aulas e janela de 30 minutos para validação facial.
- [x] **Financeiro (Manager):** Migração total para API PostgreSQL local, eliminando o Supabase Sync que causava erros na aba financeira.
- [x] **Telemetria do Sistema (Settings):** Cards reais de monitoramento de disco (Postgres) e objetos (MinIO).
- [x] **Exploradores de Infraestrutura:** Implementado acesso via botões nos cards de monitoramento para abrir janelas modais de exploração profunda (Arquivos e Banco de Dados) com navegação fluida e lightbox.
### 🚀 Infraestrutura e Deploy
- **Estado Atual:** Pipeline 100% estabilizado no GitHub Actions usando `self-hosted` runner (Oracle ARM64 nativo).

View File

@ -406,7 +406,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
onClick={() => handleSave('published')}
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all"
>
<CheckCircle size={20} /> Publicar Avaliação
<CheckCircle size={20} /> {(editingExam as any).evaluationType === 'activity' ? 'Publicar Atividade' : 'Publicar Prova'}
</button>
</div>
</div>

View File

@ -457,7 +457,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
if (!val) {
setSelectedItemType('');
setFormData(prev => ({ ...prev, amount: 0, description: '' }));
setFormData(prev => ({ ...prev, amount: 0, description: '', type: 'other' }));
return;
}
@ -476,6 +476,20 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
interest: course.interestPercentage || 0
}));
}
} else if (val.startsWith('registration_')) {
const courseId = val.replace('registration_', '');
const course = data.courses.find(c => c.id === courseId);
if (course) {
setSelectedItemType('course');
setFormData(prev => ({
...prev,
amount: course.registrationFee || 0,
description: `Taxa de Matrícula - ${course.name}`,
type: 'registration',
fine: course.finePercentage || 0,
interest: course.interestPercentage || 0
}));
}
} else if (val.startsWith('handout_')) {
const handoutId = val.replace('handout_', '');
const handout = data.handouts?.find(h => h.id === handoutId);
@ -485,7 +499,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
...prev,
amount: handout.price,
description: `Apostila - ${handout.name}`,
type: 'other',
type: 'handout',
fine: handout.finePercentage || 0,
interest: handout.interestPercentage || 0
}));
@ -1262,9 +1276,12 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Referente a (Opcional)</label>
<select className={inputClass + " w-full"} value={selectedItemId} onChange={handleItemSelect}>
<option value="">Lançamento Avulso / Personalizado</option>
<optgroup label="Cursos">
<optgroup label="Cursos (Mensalidade)">
{data.courses?.map(c => <option key={`course_${c.id}`} value={`course_${c.id}`}>{c.name} - R$ {c.monthlyFee.toFixed(2)}</option>)}
</optgroup>
<optgroup label="Taxas de Matrícula">
{data.courses?.map(c => <option key={`registration_${c.id}`} value={`registration_${c.id}`}>Matrícula - {c.name} - R$ {(c.registrationFee || 0).toFixed(2)}</option>)}
</optgroup>
<optgroup label="Apostilas">
{data.handouts?.map(h => <option key={`handout_${h.id}`} value={`handout_${h.id}`}>{h.name} - R$ {h.price.toFixed(2)}</option>)}
</optgroup>
@ -1276,6 +1293,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
<select className={inputClass + " w-full"} value={formData.type} onChange={e => setFormData({ ...formData, type: e.target.value as any })}>
<option value="monthly">Mensalidade</option>
<option value="registration">Matrícula</option>
<option value="handout">Apostila</option>
<option value="other">Outros</option>
</select>
</div>

View File

@ -34,6 +34,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const [showConfigManager, setShowConfigManager] = useState(false);
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
const [studentSubmissions, setStudentSubmissions] = useState<Record<string, {acertos: number, erros: number}>>({}); // examId -> { acertos, erros }
const subjects = data.subjects || [];
const periods = data.periods || [];
@ -109,10 +110,24 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
);
};
const handleOpenStudentGrades = (student: Student) => {
const handleOpenStudentGrades = async (student: Student) => {
setSelectedStudent(student);
const initialGrades: Record<string, Record<string, any>> = {};
try {
const res = await fetch(`/api/student-submissions/${student.id}`);
if (res.ok) {
const { submissions } = await res.json();
const subsMap: Record<string, {acertos: number, erros: number}> = {};
(submissions || []).forEach((s: any) => {
subsMap[s.prova_id] = { acertos: s.acertos, erros: s.erros };
});
setStudentSubmissions(subsMap);
}
} catch(e) {
console.error('Error fetching submissions:', e);
}
subjects.forEach(subject => {
initialGrades[subject.id] = {};
periods.forEach(period => {
@ -549,9 +564,17 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</span>
{exam.title}
</div>
{exam.description && (
<p className="text-xs text-slate-500 leading-snug pr-2">{exam.description}</p>
)}
<div className="flex flex-col gap-1">
{exam.description && (
<p className="text-xs text-slate-500 leading-snug pr-2">{exam.description}</p>
)}
{studentSubmissions[exam.id] && (
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-wider mt-1">
<span className="text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-md">{studentSubmissions[exam.id].acertos} Acertos</span>
<span className="text-red-500 bg-red-50 px-2 py-0.5 rounded-md">{studentSubmissions[exam.id].erros} Erros</span>
</div>
)}
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { SchoolData, SchoolProfile } from '../types';
import { dbService } from '../services/dbService';
import { Download, Upload, Trash2, Database, School, Camera, FileText, Info, AlertTriangle, X, CheckCircle, AlertCircle, Cloud, HelpCircle, RefreshCw, Plus, User } from 'lucide-react';
import { Download, Upload, Trash2, Database, School, Camera, FileText, Info, AlertTriangle, X, CheckCircle, AlertCircle, Cloud, HelpCircle, RefreshCw, Plus, User, Folder, File as FileIcon, Eye, ExternalLink, Image as ImageIcon, List } from 'lucide-react';
import { isSupabaseConfigured, uploadLogo } from '../services/supabase';
import { useDialog } from '../DialogContext';
import imageCompression from 'browser-image-compression';
@ -74,6 +74,70 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
};
const [systemStats, setSystemStats] = useState<any>(null);
// Storage Explorer State
const [showStorageManagerModal, setShowStorageManagerModal] = useState(false);
const [selectedStorageBucket, setSelectedStorageBucket] = useState<string | null>(null);
const [storageObjects, setStorageObjects] = useState<any[]>([]);
const [loadingBucket, setLoadingBucket] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// Database Explorer State
const [showDatabaseExplorerModal, setShowDatabaseExplorerModal] = useState(false);
const [dbTables, setDbTables] = useState<any[]>([]);
const [loadingDbTables, setLoadingDbTables] = useState(false);
const openDatabaseExplorer = async () => {
setShowDatabaseExplorerModal(true);
setLoadingDbTables(true);
try {
const res = await fetch('/api/database/tables');
const data = await res.json();
setDbTables(data.tables || []);
} catch (e) {
console.error(e);
showAlert('Erro', 'Não foi possível carregar as tabelas.', 'error');
} finally {
setLoadingDbTables(false);
}
};
const openBucket = async (bucketName: string) => {
setSelectedStorageBucket(bucketName);
setLoadingBucket(true);
try {
const res = await fetch(`/api/storage/buckets/${bucketName}/objects`);
const data = await res.json();
setStorageObjects(data.objects || []);
} catch (e) {
console.error(e);
showAlert('Erro', 'Não foi possível carregar os arquivos.', 'error');
} finally {
setLoadingBucket(false);
}
};
const deleteStorageObject = (bucket: string, key: string) => {
showConfirm('Excluir Arquivo', `Apagar permanentemente: ${key}?`, async () => {
try {
const res = await fetch(`/api/storage/buckets/${bucket}/objects`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
if (res.ok) {
setStorageObjects(prev => prev.filter(o => o.key !== key));
showAlert('Sucesso', 'Arquivo removido do disco físico.', 'success');
fetchStats(); // Update numbers
} else {
showAlert('Erro', 'Falha ao excluir arquivo.', 'error');
}
} catch (e) {
console.error(e);
}
});
};
const fetchStats = () => {
fetch('/api/system-stats')
.then(res => res.json())
@ -464,6 +528,15 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
<p className="text-xl font-black text-slate-800">{systemStats?.postgres?.tableCount || '--'} <span className="text-sm font-medium text-slate-400">PostgreSQL</span></p>
</div>
</div>
<div className="pt-4 border-t border-slate-100 mt-4 relative z-10">
<button
onClick={openDatabaseExplorer}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-blue-50 text-blue-600 rounded-xl hover:bg-blue-100 transition-all font-black text-sm shadow-sm hover:-translate-y-0.5"
>
<List size={18} /> Explorar Estrutura de Dados
</button>
</div>
</div>
{/* MINIO STORAGE CARD */}
@ -498,24 +571,14 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
</div>
</div>
{systemStats?.minio?.buckets && systemStats.minio.buckets.length > 0 && (
<div className="pt-4 border-t border-slate-100 mt-2 relative z-10">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-3">Buckets Mapeados</p>
<div className="space-y-2">
{systemStats.minio.buckets.map((b: any, idx: number) => (
<div key={idx} className="flex items-center justify-between bg-white p-3 rounded-lg border border-slate-100 shadow-sm hover:border-red-200 transition-colors">
<div className="flex items-center gap-3">
<div className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm shadow-red-200"></div>
<span className="text-sm font-bold text-slate-700">{b.name}</span>
</div>
<div className="text-xs font-bold text-slate-400">
<span className="text-slate-600">{b.items}</span> itens <span className="text-slate-600">{b.sizeMB}</span> MB
</div>
</div>
))}
</div>
</div>
)}
<div className="pt-4 border-t border-slate-100 mt-4 relative z-10">
<button
onClick={() => setShowStorageManagerModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all font-black text-sm shadow-sm hover:-translate-y-0.5"
>
<Folder size={18} /> Abrir Gerenciador de Arquivos
</button>
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
@ -711,6 +774,201 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
</div>
</div>
)}
{/* Storage Explorer Modal */}
{showStorageManagerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-in fade-in duration-300 pointer-events-auto">
<div className="bg-white rounded-3xl shadow-[0_0_50px_rgba(0,0,0,0.15)] w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-slide-up border border-slate-100">
{/* 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">
{selectedStorageBucket ? (
<button onClick={() => setSelectedStorageBucket(null)} className="p-3 bg-red-50 text-red-600 hover:bg-red-100 rounded-2xl shadow-sm transition-all" title="Voltar para Pastas">
<Cloud size={24} />
</button>
) : (
<div className="p-3 bg-red-100 text-red-600 rounded-2xl shadow-sm">
<Cloud size={28} />
</div>
)}
<div>
<h3 className="text-2xl font-black tracking-tight">{selectedStorageBucket ? selectedStorageBucket : 'Gerenciador de Arquivos'}</h3>
<p className="text-sm font-bold text-slate-500">
{selectedStorageBucket ? `${storageObjects.length} arquivos encontrados.` : `${systemStats?.minio?.buckets?.length || 0} pastas na nuvem.`}
</p>
</div>
</div>
<button
onClick={() => { setShowStorageManagerModal(false); setSelectedStorageBucket(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} />
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/30">
{!selectedStorageBucket ? (
// Pastas (Buckets) View
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{systemStats?.minio?.buckets?.map((b: any, idx: number) => (
<div key={idx} onClick={() => openBucket(b.name)} className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:border-red-300 hover:shadow-xl transition-all cursor-pointer flex items-center gap-5 hover:-translate-y-1">
<div className="p-4 bg-red-50 text-red-500 rounded-2xl group-hover:bg-red-500 group-hover:text-white transition-colors">
<Folder size={32} />
</div>
<div>
<h4 className="text-lg font-black text-slate-800">{b.name}</h4>
<p className="text-sm font-bold text-slate-400 mt-1">{b.items} arquivos {b.sizeMB} MB</p>
</div>
</div>
))}
{(!systemStats?.minio?.buckets || systemStats.minio.buckets.length === 0) && (
<div className="col-span-full py-10 text-center text-slate-400">
<Folder size={64} className="mx-auto mb-4 opacity-20" />
<p className="font-bold text-xl">Nenhuma pasta encontrada.</p>
</div>
)}
</div>
) : loadingBucket ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<RefreshCw size={48} className="animate-spin mb-4 text-red-400" />
<p className="font-bold">Acessando MinIO Storage...</p>
</div>
) : storageObjects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<Folder size={64} className="mb-4 opacity-20" />
<p className="font-bold text-xl">Pasta Vazia</p>
<p className="text-sm">Nenhum arquivo encontrado neste bucket.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{storageObjects.map((obj, i) => {
const isImage = obj.key.match(/\.(jpeg|jpg|gif|png|webp)$/i);
const isPdf = obj.key.match(/\.pdf$/i);
const sizeKB = (obj.size / 1024).toFixed(1);
return (
<div key={i} className="group bg-white rounded-2xl border border-slate-200/60 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all overflow-hidden flex flex-col">
{/* Thumbnail Area */}
<div className="h-32 bg-slate-100 relative flex items-center justify-center overflow-hidden border-b border-slate-100">
{isImage ? (
<img src={obj.url} alt={obj.key} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" loading="lazy" />
) : isPdf ? (
<FileText size={48} className="text-red-400/50 group-hover:scale-110 transition-transform" />
) : (
<FileIcon size={48} className="text-slate-300 group-hover:scale-110 transition-transform" />
)}
{/* Hover Actions Overlay */}
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3 backdrop-blur-sm">
{(isImage || isPdf) && (
<button onClick={() => setPreviewUrl(obj.url)} className="p-2.5 bg-white/20 hover:bg-white text-white hover:text-slate-900 rounded-full transition-all" title="Visualizar">
<Eye size={18} />
</button>
)}
<a href={obj.url} download={obj.key} target="_blank" rel="noreferrer" className="p-2.5 bg-white/20 hover:bg-white text-white hover:text-indigo-600 rounded-full transition-all" title="Baixar Original">
<Download size={18} />
</a>
</div>
</div>
{/* File Info */}
<div className="p-4 flex-1 flex flex-col">
<p className="text-xs font-black text-slate-700 truncate" title={obj.key}>{obj.key.split('/').pop()}</p>
<div className="mt-auto pt-3 flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase bg-slate-100 px-2 py-1 rounded-md">{sizeKB} KB</span>
<button onClick={() => deleteStorageObject(selectedStorageBucket, obj.key)} className="text-slate-300 hover:text-red-500 transition-colors" title="Excluir Permanentemente">
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}
{/* Database Explorer Modal */}
{showDatabaseExplorerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-in fade-in duration-300 pointer-events-auto">
<div className="bg-white rounded-3xl shadow-[0_0_50px_rgba(0,0,0,0.15)] w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-slide-up border border-slate-100">
{/* 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">
<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>
<p className="text-sm font-bold text-slate-500">
{dbTables.length} tabelas no schema public do PostgreSQL.
</p>
</div>
</div>
<button
onClick={() => setShowDatabaseExplorerModal(false)}
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} />
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-8 bg-slate-50/30">
{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>
</div>
) : dbTables.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
<Database size={64} className="mb-4 opacity-20" />
<p className="font-bold text-xl">Nenhuma tabela encontrada</p>
</div>
) : (
<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 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} />
</div>
<div>
<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>
<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>
)}
</div>
</div>
</div>
)}
{/* 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]">
<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" />
) : (
<iframe src={previewUrl} className="w-[85vw] h-[85vh] rounded-lg animate-in zoom-in-95" title="PDF Preview" />
)}
</div>
</div>
)}
</div>
);
};

View File

@ -101,6 +101,8 @@ const App = () => {
setSyncStatus('saved');
} else if (result.reason === 'newer_version') {
setSyncStatus('conflict');
console.warn("⚠️ Conflito de versão detectado. Sincronizando com os dados mais recentes do servidor...");
forceSyncFromCloud();
} else {
setSyncStatus('error');
}
@ -144,11 +146,20 @@ const App = () => {
try {
const cloudData = await dbService.fetchFromCloud();
if (cloudData && cloudData.lastUpdated !== dataRef.current.lastUpdated) {
console.log("🔔 Polling: Novos dados detectados no servidor!");
setData(cloudData);
dbService.saveData(cloudData);
setSyncStatus('saved');
if (cloudData && cloudData.lastUpdated && dataRef.current.lastUpdated) {
const cloudTime = new Date(cloudData.lastUpdated).getTime();
const localTime = new Date(dataRef.current.lastUpdated).getTime();
// Regra crucial: Só substitui o estado local se o servidor tiver dados ESTRITAMENTE mais novos.
// Isso impede que verificações durante o "debounce" (espera de 2 segs) sobrescrevam o estado
// local com dados velhos do servidor, fazendo itens recém-criados "sumirem".
if (cloudTime > localTime) {
console.log("🔔 Polling: Novos dados detectados no servidor!");
setData(cloudData);
dbService.saveData(cloudData);
setSyncStatus('saved');
}
}
} catch (e) {
// Silencioso em caso de erro de rede temporário

View File

@ -28,7 +28,7 @@ import {
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
updateCobrancaByField
} from './services/database.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, getMinioStats, s3Client } from './services/storage.js';
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js';
import { GetObjectCommand } from '@aws-sdk/client-s3';
const __filename = fileURLToPath(import.meta.url);
@ -227,6 +227,71 @@ app.get('/api/system-stats', async (req, res) => {
});
});
// ============================================================
// Database Explorer
// ============================================================
app.get('/api/database/tables', async (req, res) => {
try {
const query = `
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_total_relation_size(relid) as raw_size,
n_live_tup as row_count
FROM pg_stat_user_tables
ORDER BY raw_size DESC;
`;
const result = await pool.query(query);
res.json({ tables: result.rows });
} catch (error) {
console.error('Erro ao listar tabelas:', error);
res.status(500).json({ error: error.message });
}
});
// ============================================================
// MinIO Explorer
// ============================================================
app.get('/api/storage/buckets/:bucketName/objects', async (req, res) => {
try {
const { bucketName } = req.params;
const objects = await getBucketObjects(bucketName);
res.json({ objects });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/storage/buckets/:bucketName/objects', async (req, res) => {
try {
const { bucketName } = req.params;
const { key } = req.body;
if (!key) return res.status(400).json({ error: 'Key is required' });
await deleteMinioObject(bucketName, key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ============================================================
// Rota para buscar submissões (acertos/erros) do aluno
// ============================================================
app.get('/api/student-submissions/:studentId', async (req, res) => {
try {
const { studentId } = req.params;
const { rows } = await pool.query(
'SELECT prova_id, acertos, erros FROM provas_submissoes WHERE aluno_id = $1',
[studentId]
);
res.json({ submissions: rows });
} catch (err) {
console.error('Erro ao buscar submissões do aluno:', err);
res.status(500).json({ error: 'Erro interno' });
}
});
// ============================================================
// Upload de Logo (MinIO em vez de Supabase Storage)
// ============================================================

View File

@ -231,8 +231,7 @@ export const dbService = {
saveData: async (data: SchoolData) => {
try {
// Update timestamp
data.lastUpdated = new Date().toISOString();
// Note: timestamp updating is handled by updateData in index.tsx to avoid mutating React state.
// Save to IndexedDB (cache local)
const db = await openDB();

View File

@ -4,7 +4,7 @@
* Substitui todas as chamadas supabase.storage do sistema
* ============================================================
*/
import { S3Client, PutObjectCommand, GetObjectCommand, ListBucketsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { S3Client, PutObjectCommand, GetObjectCommand, ListBucketsCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3';
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'minio';
const MINIO_PORT = process.env.MINIO_PORT || '9000';
@ -136,4 +136,32 @@ export async function getMinioStats() {
}
}
export async function getBucketObjects(bucketName) {
try {
const data = await s3Client.send(new ListObjectsV2Command({ Bucket: bucketName }));
if (!data.Contents) return [];
return data.Contents.map(obj => ({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified,
url: `/storage/${bucketName}/${obj.Key}`
}));
} catch (error) {
console.error(`Erro ao listar objetos do bucket ${bucketName}:`, error);
throw error;
}
}
export async function deleteMinioObject(bucketName, key) {
try {
const command = new DeleteObjectCommand({ Bucket: bucketName, Key: key });
await s3Client.send(command);
return true;
} catch (error) {
console.error(`Erro ao deletar objeto ${key} do bucket ${bucketName}:`, error);
throw error;
}
}
export { s3Client };

View File

@ -101,7 +101,7 @@ export interface Payment {
dueDate: string;
status: 'pending' | 'paid' | 'overdue';
paidDate?: string;
type: 'monthly' | 'registration' | 'other';
type: 'monthly' | 'registration' | 'other' | 'handout';
installmentNumber?: number;
totalInstallments?: number;
description?: string;

View File

@ -248,17 +248,29 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
const grades = (schoolData.grades || []).filter((g) => g.studentId === req.user.studentId);
const subjects = schoolData.subjects || [];
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
// Buscar submissões para pegar acertos e erros
const { rows: submissions } = await pool.query(
'SELECT prova_id, acertos, erros FROM provas_submissoes WHERE aluno_id = $1',
[req.user.studentId]
);
const enrichedGrades = grades.map((g) => {
const subject = subjects.find((s) => s.id === g.subjectId);
const exam = g.examId ? (schoolData.exams || []).find(e => e.id === g.examId) : null;
const periodObj = (schoolData.periods || []).find(p => p.id === g.period);
const submission = g.examId ? submissions.find(s => s.prova_id === g.examId) : null;
return {
...g,
subjectName: subject?.name || 'Disciplina desconhecida',
examTitle: exam?.title,
evaluationType: exam?.evaluationType || 'exam',
maxScore: exam?.maxScore,
periodName: periodObj ? periodObj.name : g.period
periodName: periodObj ? periodObj.name : g.period,
correctCount: submission?.acertos,
wrongCount: submission?.erros
};
});
const periods = [...new Set(enrichedGrades.map((g) => g.periodName))];
@ -332,6 +344,7 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
schoolData.attendance = attendance;
schoolData.notifications = notifications;
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] });
@ -422,6 +435,7 @@ app.put('/api/portal/notificacoes/ler/:id', authMiddleware, async (req, res) =>
if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' });
notifications[idx] = { ...notifications[idx], read: true };
schoolData.notifications = notifications;
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (err) {
@ -437,6 +451,7 @@ app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
schoolData.notifications = (schoolData.notifications || []).filter(
n => !(n.id === id && n.studentId === req.user.studentId)
);
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
res.json({ success: true });
} catch (err) {
@ -462,6 +477,7 @@ app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => {
students[studentIndex] = { ...student, portalPassword: newPassword };
schoolData.students = students;
schoolData.lastUpdated = new Date().toISOString();
await saveSchoolData(schoolData);
res.json({ message: 'Senha alterada com sucesso' });
@ -572,6 +588,7 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
});
}
schoolData.grades = grades;
schoolData.lastUpdated = new Date().toISOString(); // Garante que o Manager detecte a mudança
await saveSchoolData(schoolData);
}

View File

@ -9,6 +9,8 @@ interface GradeWithSubject extends Grade {
evaluationType?: string;
maxScore?: number;
periodName?: string;
correctCount?: number;
wrongCount?: number;
}
export default function Notas() {
@ -181,8 +183,15 @@ export default function Notas() {
{isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
</div>
{!isDirect && (
<div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)' }}>
{isActivity ? 'ATIVIDADE' : 'PROVA'} VALE: {maxScore} PTS
<div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)', display: 'flex', gap: '8px', alignItems: 'center' }}>
<span>{isActivity ? 'ATIVIDADE' : 'PROVA'} VALE: {maxScore} PTS</span>
{grade.correctCount !== undefined && grade.wrongCount !== undefined && (
<>
<span style={{ color: 'var(--glass-border)' }}>|</span>
<span style={{ color: 'var(--color-success)' }}>{grade.correctCount} Acertos</span>
<span style={{ color: 'var(--color-danger)' }}>{grade.wrongCount} Erros</span>
</>
)}
</div>
)}
</div>