From f4ddee486a04733b23f5a18897bfbe13af31a3b0 Mon Sep 17 00:00:00 2001 From: Sidney Date: Wed, 29 Apr 2026 19:16:59 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20exploradores=20de=20storage=20e=20datab?= =?UTF-8?q?ase,=20melhorias=20financeiras=20e=20padroniza=C3=A7=C3=A3o=20d?= =?UTF-8?q?e=20modais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GEMINI.md | 2 + MEMORY.md | 14 +- manager/components/Exams.tsx | 2 +- manager/components/Finance.tsx | 24 ++- manager/components/ReportCard.tsx | 31 +++- manager/components/Settings.tsx | 296 ++++++++++++++++++++++++++++-- manager/index.tsx | 21 ++- manager/server.selfhosted.js | 67 ++++++- manager/services/dbService.ts | 3 +- manager/services/storage.js | 30 ++- manager/types.ts | 2 +- portal/server.selfhosted.js | 19 +- portal/src/pages/Notas.tsx | 13 +- 13 files changed, 479 insertions(+), 45 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 19e23ff..10372d1 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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). diff --git a/MEMORY.md b/MEMORY.md index 8b2e81a..9177015 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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). diff --git a/manager/components/Exams.tsx b/manager/components/Exams.tsx index 4f1c5d2..ef53840 100644 --- a/manager/components/Exams.tsx +++ b/manager/components/Exams.tsx @@ -406,7 +406,7 @@ const Exams: React.FC = ({ 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" > - Publicar Avaliação + {(editingExam as any).evaluationType === 'activity' ? 'Publicar Atividade' : 'Publicar Prova'} diff --git a/manager/components/Finance.tsx b/manager/components/Finance.tsx index 4dfb79f..eadf0e6 100644 --- a/manager/components/Finance.tsx +++ b/manager/components/Finance.tsx @@ -457,7 +457,7 @@ const Finance: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ data, updateData }) => { setFormData({ ...formData, type: e.target.value as any })}> + diff --git a/manager/components/ReportCard.tsx b/manager/components/ReportCard.tsx index 1fd01c9..900d592 100644 --- a/manager/components/ReportCard.tsx +++ b/manager/components/ReportCard.tsx @@ -34,6 +34,7 @@ const ReportCard: React.FC = ({ data, updateData }) => { const [showConfigManager, setShowConfigManager] = useState(false); const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects'); const [studentGrades, setStudentGrades] = useState>>({}); // subjectId -> periodId -> { examId: value } + const [studentSubmissions, setStudentSubmissions] = useState>({}); // examId -> { acertos, erros } const subjects = data.subjects || []; const periods = data.periods || []; @@ -109,9 +110,23 @@ const ReportCard: React.FC = ({ data, updateData }) => { ); }; - const handleOpenStudentGrades = (student: Student) => { + const handleOpenStudentGrades = async (student: Student) => { setSelectedStudent(student); const initialGrades: Record> = {}; + + try { + const res = await fetch(`/api/student-submissions/${student.id}`); + if (res.ok) { + const { submissions } = await res.json(); + const subsMap: Record = {}; + (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] = {}; @@ -549,9 +564,17 @@ const ReportCard: React.FC = ({ data, updateData }) => { {exam.title} - {exam.description && ( -

{exam.description}

- )} +
+ {exam.description && ( +

{exam.description}

+ )} + {studentSubmissions[exam.id] && ( +
+ {studentSubmissions[exam.id].acertos} Acertos + {studentSubmissions[exam.id].erros} Erros +
+ )} +
diff --git a/manager/components/Settings.tsx b/manager/components/Settings.tsx index ba48189..3d3cfa0 100644 --- a/manager/components/Settings.tsx +++ b/manager/components/Settings.tsx @@ -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 = ({ data, updateData, setData }) => { }; const [systemStats, setSystemStats] = useState(null); + // Storage Explorer State + const [showStorageManagerModal, setShowStorageManagerModal] = useState(false); + const [selectedStorageBucket, setSelectedStorageBucket] = useState(null); + const [storageObjects, setStorageObjects] = useState([]); + const [loadingBucket, setLoadingBucket] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + + // Database Explorer State + const [showDatabaseExplorerModal, setShowDatabaseExplorerModal] = useState(false); + const [dbTables, setDbTables] = useState([]); + 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 = ({ data, updateData, setData }) => {

{systemStats?.postgres?.tableCount || '--'} PostgreSQL

+ +
+ +
{/* MINIO STORAGE CARD */} @@ -498,24 +571,14 @@ const Settings: React.FC = ({ data, updateData, setData }) => { - {systemStats?.minio?.buckets && systemStats.minio.buckets.length > 0 && ( -
-

Buckets Mapeados

-
- {systemStats.minio.buckets.map((b: any, idx: number) => ( -
-
-
- {b.name} -
-
- {b.items} itens • {b.sizeMB} MB -
-
- ))} -
-
- )} +
+ +
@@ -711,6 +774,201 @@ const Settings: React.FC = ({ data, updateData, setData }) => {
)} + + {/* Storage Explorer Modal */} + {showStorageManagerModal && ( +
+
+ {/* Header */} +
+
+ {selectedStorageBucket ? ( + + ) : ( +
+ +
+ )} +
+

{selectedStorageBucket ? selectedStorageBucket : 'Gerenciador de Arquivos'}

+

+ {selectedStorageBucket ? `${storageObjects.length} arquivos encontrados.` : `${systemStats?.minio?.buckets?.length || 0} pastas na nuvem.`} +

+
+
+ +
+ + {/* Content Body */} +
+ {!selectedStorageBucket ? ( + // Pastas (Buckets) View +
+ {systemStats?.minio?.buckets?.map((b: any, idx: number) => ( +
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"> +
+ +
+
+

{b.name}

+

{b.items} arquivos • {b.sizeMB} MB

+
+
+ ))} + {(!systemStats?.minio?.buckets || systemStats.minio.buckets.length === 0) && ( +
+ +

Nenhuma pasta encontrada.

+
+ )} +
+ ) : loadingBucket ? ( +
+ +

Acessando MinIO Storage...

+
+ ) : storageObjects.length === 0 ? ( +
+ +

Pasta Vazia

+

Nenhum arquivo encontrado neste bucket.

+
+ ) : ( +
+ {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 ( +
+ {/* Thumbnail Area */} +
+ {isImage ? ( + {obj.key} + ) : isPdf ? ( + + ) : ( + + )} + + {/* Hover Actions Overlay */} +
+ {(isImage || isPdf) && ( + + )} + + + +
+
+ + {/* File Info */} +
+

{obj.key.split('/').pop()}

+
+ {sizeKB} KB + +
+
+
+ ); + })} +
+ )} +
+
+
+ )} + + {/* Database Explorer Modal */} + {showDatabaseExplorerModal && ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Database Explorer

+

+ {dbTables.length} tabelas no schema public do PostgreSQL. +

+
+
+ +
+ + {/* Content Body */} +
+ {loadingDbTables ? ( +
+ +

Analisando Estrutura do PostgreSQL...

+
+ ) : dbTables.length === 0 ? ( +
+ +

Nenhuma tabela encontrada

+
+ ) : ( +
+ {dbTables.map((table, idx) => ( +
+
+
+ +
+
+

{table.table_name}

+
+ {table.row_count} registros +
+
+
+ {table.total_size} +
+ ))} +
+ )} +
+
+
+ )} + + {/* Lightbox Preview */} + {previewUrl && ( +
+ +
+ {previewUrl.match(/\.(jpeg|jpg|gif|png|webp)$/i) ? ( + Preview + ) : ( +