feat: exploradores de storage e database, melhorias financeiras e padronização de modais
This commit is contained in:
parent
bf4ebd8b6b
commit
f4ddee486a
|
|
@ -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.
|
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.
|
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.
|
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).
|
||||||
|
|
|
||||||
14
MEMORY.md
14
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] 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] 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] **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] **Boletim Detalhado (Manager):** Refatoração para suportar N avaliações por bimestre, com interface que diferencia Provas de 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] **Ambiente de Provas (Portal):** Implementado modo imersivo com cronômetro pulsante (alerta vermelho < 1min) e etiquetas dinâmicas por tipo de avaliação.
|
||||||
- [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] **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] **Nomenclatura Unificada:** Alterado "Avaliações" para **"Atividades e Provas"** em todo o ecossistema (Portal e Manager).
|
- [x] **Sincronia Total:** Integração via `examId` garantindo que notas do portal preencham automaticamente o boletim administrativo.
|
||||||
- [ ] Próximo Passo: Analisar a necessidade de pesos diferenciados (médias ponderadas) entre Atividades e Provas no cálculo do boletim.
|
- [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)
|
### 💳 Módulo Financeiro (Portal do Aluno)
|
||||||
- **Funcionalidades Implementadas:**
|
- **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] **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] **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] **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
|
### 🚀 Infraestrutura e Deploy
|
||||||
- **Estado Atual:** Pipeline 100% estabilizado no GitHub Actions usando `self-hosted` runner (Oracle ARM64 nativo).
|
- **Estado Atual:** Pipeline 100% estabilizado no GitHub Actions usando `self-hosted` runner (Oracle ARM64 nativo).
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
onClick={() => handleSave('published')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
if (!val) {
|
if (!val) {
|
||||||
setSelectedItemType('');
|
setSelectedItemType('');
|
||||||
setFormData(prev => ({ ...prev, amount: 0, description: '' }));
|
setFormData(prev => ({ ...prev, amount: 0, description: '', type: 'other' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -476,6 +476,20 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
interest: course.interestPercentage || 0
|
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_')) {
|
} else if (val.startsWith('handout_')) {
|
||||||
const handoutId = val.replace('handout_', '');
|
const handoutId = val.replace('handout_', '');
|
||||||
const handout = data.handouts?.find(h => h.id === handoutId);
|
const handout = data.handouts?.find(h => h.id === handoutId);
|
||||||
|
|
@ -485,7 +499,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
...prev,
|
...prev,
|
||||||
amount: handout.price,
|
amount: handout.price,
|
||||||
description: `Apostila - ${handout.name}`,
|
description: `Apostila - ${handout.name}`,
|
||||||
type: 'other',
|
type: 'handout',
|
||||||
fine: handout.finePercentage || 0,
|
fine: handout.finePercentage || 0,
|
||||||
interest: handout.interestPercentage || 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>
|
<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}>
|
<select className={inputClass + " w-full"} value={selectedItemId} onChange={handleItemSelect}>
|
||||||
<option value="">Lançamento Avulso / Personalizado</option>
|
<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>)}
|
{data.courses?.map(c => <option key={`course_${c.id}`} value={`course_${c.id}`}>{c.name} - R$ {c.monthlyFee.toFixed(2)}</option>)}
|
||||||
</optgroup>
|
</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">
|
<optgroup label="Apostilas">
|
||||||
{data.handouts?.map(h => <option key={`handout_${h.id}`} value={`handout_${h.id}`}>{h.name} - R$ {h.price.toFixed(2)}</option>)}
|
{data.handouts?.map(h => <option key={`handout_${h.id}`} value={`handout_${h.id}`}>{h.name} - R$ {h.price.toFixed(2)}</option>)}
|
||||||
</optgroup>
|
</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 })}>
|
<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="monthly">Mensalidade</option>
|
||||||
<option value="registration">Matrícula</option>
|
<option value="registration">Matrícula</option>
|
||||||
|
<option value="handout">Apostila</option>
|
||||||
<option value="other">Outros</option>
|
<option value="other">Outros</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
const [showConfigManager, setShowConfigManager] = useState(false);
|
const [showConfigManager, setShowConfigManager] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
|
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
|
||||||
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
|
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 subjects = data.subjects || [];
|
||||||
const periods = data.periods || [];
|
const periods = data.periods || [];
|
||||||
|
|
@ -109,9 +110,23 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenStudentGrades = (student: Student) => {
|
const handleOpenStudentGrades = async (student: Student) => {
|
||||||
setSelectedStudent(student);
|
setSelectedStudent(student);
|
||||||
const initialGrades: Record<string, Record<string, any>> = {};
|
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 => {
|
subjects.forEach(subject => {
|
||||||
initialGrades[subject.id] = {};
|
initialGrades[subject.id] = {};
|
||||||
|
|
@ -549,9 +564,17 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
</span>
|
</span>
|
||||||
{exam.title}
|
{exam.title}
|
||||||
</div>
|
</div>
|
||||||
{exam.description && (
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-xs text-slate-500 leading-snug pr-2">{exam.description}</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { SchoolData, SchoolProfile } from '../types';
|
import { SchoolData, SchoolProfile } from '../types';
|
||||||
import { dbService } from '../services/dbService';
|
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 { isSupabaseConfigured, uploadLogo } from '../services/supabase';
|
||||||
import { useDialog } from '../DialogContext';
|
import { useDialog } from '../DialogContext';
|
||||||
import imageCompression from 'browser-image-compression';
|
import imageCompression from 'browser-image-compression';
|
||||||
|
|
@ -74,6 +74,70 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
|
||||||
};
|
};
|
||||||
const [systemStats, setSystemStats] = useState<any>(null);
|
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 = () => {
|
const fetchStats = () => {
|
||||||
fetch('/api/system-stats')
|
fetch('/api/system-stats')
|
||||||
.then(res => res.json())
|
.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>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* MINIO STORAGE CARD */}
|
{/* MINIO STORAGE CARD */}
|
||||||
|
|
@ -498,24 +571,14 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{systemStats?.minio?.buckets && systemStats.minio.buckets.length > 0 && (
|
<div className="pt-4 border-t border-slate-100 mt-4 relative z-10">
|
||||||
<div className="pt-4 border-t border-slate-100 mt-2 relative z-10">
|
<button
|
||||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-3">Buckets Mapeados</p>
|
onClick={() => setShowStorageManagerModal(true)}
|
||||||
<div className="space-y-2">
|
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"
|
||||||
{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">
|
<Folder size={18} /> Abrir Gerenciador de Arquivos
|
||||||
<div className="flex items-center gap-3">
|
</button>
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm shadow-red-200"></div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ const App = () => {
|
||||||
setSyncStatus('saved');
|
setSyncStatus('saved');
|
||||||
} else if (result.reason === 'newer_version') {
|
} else if (result.reason === 'newer_version') {
|
||||||
setSyncStatus('conflict');
|
setSyncStatus('conflict');
|
||||||
|
console.warn("⚠️ Conflito de versão detectado. Sincronizando com os dados mais recentes do servidor...");
|
||||||
|
forceSyncFromCloud();
|
||||||
} else {
|
} else {
|
||||||
setSyncStatus('error');
|
setSyncStatus('error');
|
||||||
}
|
}
|
||||||
|
|
@ -144,11 +146,20 @@ const App = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cloudData = await dbService.fetchFromCloud();
|
const cloudData = await dbService.fetchFromCloud();
|
||||||
if (cloudData && cloudData.lastUpdated !== dataRef.current.lastUpdated) {
|
|
||||||
console.log("🔔 Polling: Novos dados detectados no servidor!");
|
if (cloudData && cloudData.lastUpdated && dataRef.current.lastUpdated) {
|
||||||
setData(cloudData);
|
const cloudTime = new Date(cloudData.lastUpdated).getTime();
|
||||||
dbService.saveData(cloudData);
|
const localTime = new Date(dataRef.current.lastUpdated).getTime();
|
||||||
setSyncStatus('saved');
|
|
||||||
|
// 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) {
|
} catch (e) {
|
||||||
// Silencioso em caso de erro de rede temporário
|
// Silencioso em caso de erro de rede temporário
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
|
getCobrancasByInstallmentId, updateCobrancaLinkCarne,
|
||||||
updateCobrancaByField
|
updateCobrancaByField
|
||||||
} from './services/database.js';
|
} 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';
|
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
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)
|
// Upload de Logo (MinIO em vez de Supabase Storage)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -231,8 +231,7 @@ export const dbService = {
|
||||||
|
|
||||||
saveData: async (data: SchoolData) => {
|
saveData: async (data: SchoolData) => {
|
||||||
try {
|
try {
|
||||||
// Update timestamp
|
// Note: timestamp updating is handled by updateData in index.tsx to avoid mutating React state.
|
||||||
data.lastUpdated = new Date().toISOString();
|
|
||||||
|
|
||||||
// Save to IndexedDB (cache local)
|
// Save to IndexedDB (cache local)
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Substitui todas as chamadas supabase.storage do sistema
|
* 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_ENDPOINT = process.env.MINIO_ENDPOINT || 'minio';
|
||||||
const MINIO_PORT = process.env.MINIO_PORT || '9000';
|
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 };
|
export { s3Client };
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export interface Payment {
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
status: 'pending' | 'paid' | 'overdue';
|
status: 'pending' | 'paid' | 'overdue';
|
||||||
paidDate?: string;
|
paidDate?: string;
|
||||||
type: 'monthly' | 'registration' | 'other';
|
type: 'monthly' | 'registration' | 'other' | 'handout';
|
||||||
installmentNumber?: number;
|
installmentNumber?: number;
|
||||||
totalInstallments?: number;
|
totalInstallments?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
|
||||||
|
|
@ -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 grades = (schoolData.grades || []).filter((g) => g.studentId === req.user.studentId);
|
||||||
const subjects = schoolData.subjects || [];
|
const subjects = schoolData.subjects || [];
|
||||||
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
|
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 enrichedGrades = grades.map((g) => {
|
||||||
const subject = subjects.find((s) => s.id === g.subjectId);
|
const subject = subjects.find((s) => s.id === g.subjectId);
|
||||||
const exam = g.examId ? (schoolData.exams || []).find(e => e.id === g.examId) : null;
|
const exam = g.examId ? (schoolData.exams || []).find(e => e.id === g.examId) : null;
|
||||||
const periodObj = (schoolData.periods || []).find(p => p.id === g.period);
|
const periodObj = (schoolData.periods || []).find(p => p.id === g.period);
|
||||||
|
|
||||||
|
const submission = g.examId ? submissions.find(s => s.prova_id === g.examId) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...g,
|
...g,
|
||||||
subjectName: subject?.name || 'Disciplina desconhecida',
|
subjectName: subject?.name || 'Disciplina desconhecida',
|
||||||
examTitle: exam?.title,
|
examTitle: exam?.title,
|
||||||
evaluationType: exam?.evaluationType || 'exam',
|
evaluationType: exam?.evaluationType || 'exam',
|
||||||
maxScore: exam?.maxScore,
|
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))];
|
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.attendance = attendance;
|
||||||
schoolData.notifications = notifications;
|
schoolData.notifications = notifications;
|
||||||
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
|
|
||||||
res.json({ message: 'Justificativa enviada com sucesso', record: attendance[recordIndex] });
|
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' });
|
if (idx === -1) return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||||
notifications[idx] = { ...notifications[idx], read: true };
|
notifications[idx] = { ...notifications[idx], read: true };
|
||||||
schoolData.notifications = notifications;
|
schoolData.notifications = notifications;
|
||||||
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -437,6 +451,7 @@ app.delete('/api/portal/notificacoes/:id', authMiddleware, async (req, res) => {
|
||||||
schoolData.notifications = (schoolData.notifications || []).filter(
|
schoolData.notifications = (schoolData.notifications || []).filter(
|
||||||
n => !(n.id === id && n.studentId === req.user.studentId)
|
n => !(n.id === id && n.studentId === req.user.studentId)
|
||||||
);
|
);
|
||||||
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -462,6 +477,7 @@ app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => {
|
||||||
|
|
||||||
students[studentIndex] = { ...student, portalPassword: newPassword };
|
students[studentIndex] = { ...student, portalPassword: newPassword };
|
||||||
schoolData.students = students;
|
schoolData.students = students;
|
||||||
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
|
|
||||||
res.json({ message: 'Senha alterada com sucesso' });
|
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.grades = grades;
|
||||||
|
schoolData.lastUpdated = new Date().toISOString(); // Garante que o Manager detecte a mudança
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ interface GradeWithSubject extends Grade {
|
||||||
evaluationType?: string;
|
evaluationType?: string;
|
||||||
maxScore?: number;
|
maxScore?: number;
|
||||||
periodName?: string;
|
periodName?: string;
|
||||||
|
correctCount?: number;
|
||||||
|
wrongCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Notas() {
|
export default function Notas() {
|
||||||
|
|
@ -181,8 +183,15 @@ export default function Notas() {
|
||||||
{isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
|
{isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
|
||||||
</div>
|
</div>
|
||||||
{!isDirect && (
|
{!isDirect && (
|
||||||
<div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)' }}>
|
<div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
{isActivity ? 'ATIVIDADE' : 'PROVA'} • VALE: {maxScore} PTS
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue