feat: migração completa storage MinIO, telemetria real de DB e correção de frequência

This commit is contained in:
Sidney 2026-04-21 20:47:46 -03:00
parent 1d64650895
commit 715304fee5
11 changed files with 4642 additions and 194 deletions

File diff suppressed because one or more lines are too long

View File

@ -152,9 +152,10 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
return; return;
} }
// Check if there is already a record for this lesson // Check if there is already a record for this lesson specifically
const existingIndex = (data.attendance || []).findIndex(a => const existingIndex = (data.attendance || []).findIndex(a =>
a.studentId === absenceStudentId && a.date.startsWith(lesson.date) a.studentId === absenceStudentId &&
((a as any).lessonId === lesson.id || a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`)
); );
let updatedAttendance = [...(data.attendance || [])]; let updatedAttendance = [...(data.attendance || [])];
@ -165,7 +166,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
type: 'absence', type: 'absence',
justification: absenceJustification, justification: absenceJustification,
justificationAccepted: true, justificationAccepted: true,
verified: true verified: true,
lessonId: lesson.id as any
}; };
} else { } else {
const newAbsence: Attendance = { const newAbsence: Attendance = {
@ -176,7 +178,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
verified: true, verified: true,
type: 'absence', type: 'absence',
justification: absenceJustification, justification: absenceJustification,
justificationAccepted: true justificationAccepted: true,
...(lesson ? { lessonId: lesson.id } : {}) as any
}; };
updatedAttendance.push(newAbsence); updatedAttendance.push(newAbsence);
} }
@ -352,11 +355,43 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
); );
} }
return classStudents.map(student => { const studentActualRecords = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id);
const studentAttendance = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id); const classLessonsRaw = (data.lessons || []).filter(l => l.classId === selectedClass.id && l.status !== 'cancelled');
const presences = studentAttendance.filter(a => a.type === 'presence' || a.type !== 'absence').length;
const absences = studentAttendance.filter(a => a.type === 'absence').length; const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
const justified = studentAttendance.filter(a => a.type === 'absence' && a.justificationAccepted).length; index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
let presences = 0;
let absences = 0;
let justified = 0;
const now = new Date();
deduplicatedLessons.forEach(lesson => {
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
const matchedRecord = studentActualRecords.find(a => {
if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
});
if (matchedRecord) {
if (matchedRecord.type === 'absence') {
if (matchedRecord.justificationAccepted) justified++;
else absences++;
} else if (matchedRecord.type === 'presence' || !matchedRecord.type) {
presences++;
}
} else if (now > lessonEnd) {
absences++;
}
});
return ( return (
<div <div
@ -431,65 +466,51 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const actualRecords = (data.attendance || []) const actualRecords = (data.attendance || [])
.filter(a => a.studentId === selectedStudent.id && a.classId === selectedClass.id); .filter(a => a.studentId === selectedStudent.id && a.classId === selectedClass.id);
const classLessons = (data.lessons || []) const classLessonsRaw = (data.lessons || [])
.filter(l => l.classId === selectedClass.id && l.status !== 'cancelled'); .filter(l => l.classId === selectedClass.id && l.status !== 'cancelled');
const virtualRecords: any[] = []; const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
const matchedActualRecordIds = new Set<string>(); index === self.findIndex((t) => (
t.date === lesson.date && t.startTime === lesson.startTime
))
);
const tableRows: any[] = [];
classLessons.forEach(lesson => { deduplicatedLessons.forEach(lesson => {
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00'); const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before
// Lógica de "Casamento" refinada para evitar duplicidade let record = actualRecords.find(a => {
const matchingRecord = actualRecords.find(a => {
// Match forte se já tiver lessonId (registros criados a partir daqui)
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
// Match de horário exato (manual antigo)
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
// Match por Janela de Tempo (Biometria): entre 30 min antes da aula até o fim da aula
const recordTime = new Date(a.date); const recordTime = new Date(a.date);
return recordTime >= presenceStartWindow && recordTime <= lessonEnd; return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
}); });
if (!matchingRecord) {
// Não tem ponto registrado ainda. Criar o virtual apenas se já estiver na janela
if (now >= presenceStartWindow) {
const isFinished = now > lessonEnd;
virtualRecords.push({ if (!record && now >= presenceStartWindow) {
id: `v-${lesson.id}`, const isFinished = now > lessonEnd;
studentId: selectedStudent.id, record = {
classId: selectedClass.id, id: `v-${lesson.id}`,
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`, studentId: selectedStudent.id,
type: isFinished ? 'absence' : 'awaiting', classId: selectedClass.id,
isVirtual: true, date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
lessonId: lesson.id, type: isFinished ? 'absence' : 'awaiting',
awaiting: !isFinished isVirtual: true,
}); lessonId: lesson.id,
} awaiting: !isFinished
} else { };
(matchingRecord as any).lessonId = lesson.id; }
matchedActualRecordIds.add(matchingRecord.id);
if (record) {
tableRows.push({ lesson, record });
} }
}); });
// Filtrar os records reais para evitar duplicidade na lista caso haja lixo no banco tableRows.sort((a, b) => new Date(b.lesson.date + 'T' + (b.lesson.startTime || '00:00') + ':00').getTime() - new Date(a.lesson.date + 'T' + (a.lesson.startTime || '00:00') + ':00').getTime());
// Só mostra os reais que casaram com aulas válidas, MAIS qualquer registro avulso
// que, por algum motivo exótico não casou (fallback de segurança visual)
const uniqueActualRecords = actualRecords.filter(a => {
if (matchedActualRecordIds.has(a.id)) return true;
// Se não casou, e a aula for do mesmo dia, ignora para não poluir (provavelmente duplicata de biometria)
const hasLessonSameDay = classLessons.some(l => a.date.startsWith(l.date));
return !hasLessonSameDay;
});
const studentRecords = [...uniqueActualRecords, ...virtualRecords] if (tableRows.length === 0) {
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (studentRecords.length === 0) {
return ( return (
<div className="text-center py-16 text-slate-400"> <div className="text-center py-16 text-slate-400">
<Calendar size={48} className="mx-auto mb-4 opacity-20" /> <Calendar size={48} className="mx-auto mb-4 opacity-20" />
@ -498,9 +519,18 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
); );
} }
const presences = studentRecords.filter(a => a.type === 'presence' || (!a.type && !a.isVirtual)).length; let presences = 0;
const absences = studentRecords.filter(a => a.type === 'absence').length; let absences = 0;
const justified = studentRecords.filter(a => a.type === 'absence' && a.justificationAccepted).length; let justified = 0;
tableRows.forEach(row => {
if (row.record.type === 'absence') {
if (row.record.justificationAccepted) justified++;
else absences++;
} else if (row.record.type === 'presence' || (!row.record.type && !row.record.isVirtual)) {
presences++;
}
});
return ( return (
<> <>
@ -516,7 +546,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<AlertCircle size={14} /> {justified} Justificadas <AlertCircle size={14} /> {justified} Justificadas
</div> </div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-100 text-indigo-700 rounded-lg"> <div className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-100 text-indigo-700 rounded-lg">
<BookOpen size={14} /> {studentRecords.length} Aulas <BookOpen size={14} /> {tableRows.length} Aulas
</div> </div>
</div> </div>
@ -536,16 +566,9 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
{studentRecords.map(record => { {tableRows.map(({lesson, record}) => {
const recordDate = new Date(record.date); const recordDate = new Date(record.date);
const time = recordDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const time = recordDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Find corresponding lesson times with high precision
const lesson = data.lessons.find(l =>
(record.isVirtual && record.id === `v-${l.id}`) ||
(record.lessonId === l.id) ||
(!record.lessonId && l.date === record.date.split('T')[0] && l.classId === record.classId)
);
let justMotivo = record.justification || ''; let justMotivo = record.justification || '';
let justAttachment: string | null = null; let justAttachment: string | null = null;

View File

@ -113,7 +113,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
const errorMessage = error.message || 'Erro desconhecido'; const errorMessage = error.message || 'Erro desconhecido';
alert(`Erro ao enviar imagem: ${errorMessage}\n\nCertifique-se de que o bucket "edumanager-assets" existe no Supabase e tem as permissões corretas.`); alert(`Erro ao enviar imagem: ${errorMessage}\n\nVerifique sua conexão ou a configuração do bucket "exames" no MinIO.`);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
if (event.target) { if (event.target) {

View File

@ -58,6 +58,14 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
const [activeTab, setActiveTab] = useState<'perfil' | 'monitoramento'>('perfil'); const [activeTab, setActiveTab] = useState<'perfil' | 'monitoramento'>('perfil');
const [apiLogs, setApiLogs] = useState<any[]>([]); const [apiLogs, setApiLogs] = useState<any[]>([]);
const [systemStats, setSystemStats] = useState<any>(null);
React.useEffect(() => {
fetch('/api/system-stats')
.then(res => res.json())
.then(data => setSystemStats(data))
.catch(err => console.error('Erro ao buscar stats do sistema:', err));
}, []);
React.useEffect(() => { React.useEffect(() => {
if (activeTab === 'monitoramento') { if (activeTab === 'monitoramento') {
@ -186,49 +194,7 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
); );
}; };
const [isSyncing, setIsSyncing] = useState(false);
const supabaseConfigured = useMemo(() => isSupabaseConfigured(), []);
const [showImportModal, setShowImportModal] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const downloadSupabaseSQL = () => {
const sql = `-- Create the table for storing the entire application state as a JSON blob
create table if not exists school_data (
id bigint primary key,
data jsonb not null default '{}'::jsonb,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Insert the initial row (id=1) if it doesn't exist so the app has something to fetch/update
insert into school_data (id, data)
values (1, '{}'::jsonb)
on conflict (id) do nothing;
-- Enable Row Level Security (RLS)
alter table school_data enable row level security;
-- Create a policy that allows anyone to read/write (for development/demo purposes)
-- In a real production app, you would restrict this to authenticated users
create policy "Enable read access for all users"
on school_data for select
using (true);
create policy "Enable insert access for all users"
on school_data for insert
with check (true);
create policy "Enable update access for all users"
on school_data for update
using (true);`;
const blob = new Blob([sql], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'supabase_setup.sql';
a.click();
URL.revokeObjectURL(url);
};
const closeModal = () => { const closeModal = () => {
setIsClosing(true); setIsClosing(true);
@ -253,27 +219,13 @@ using (true);`;
const compressedFile = await imageCompression(file, options); const compressedFile = await imageCompression(file, options);
let logoUrl = ''; const url = await uploadLogo(compressedFile);
if (!url) {
// Try to upload to Supabase if configured throw new Error("Falha ao obter a URL da logo após o upload");
if (supabaseConfigured) {
const url = await uploadLogo(compressedFile);
if (url) {
logoUrl = url;
}
}
// Fallback to base64 if Supabase upload failed or not configured
if (!logoUrl) {
const reader = new FileReader();
logoUrl = await new Promise((resolve) => {
reader.onload = (e) => resolve(e.target?.result as string);
reader.readAsDataURL(compressedFile);
});
} }
setGlobalLogo(logoUrl); setGlobalLogo(url);
updateData({ logo: logoUrl }); updateData({ logo: url });
showAlert('Sucesso', 'Logo atualizada com sucesso!', 'success'); showAlert('Sucesso', 'Logo atualizada com sucesso!', 'success');
} catch (error) { } catch (error) {
console.error('Erro ao fazer upload da imagem:', error); console.error('Erro ao fazer upload da imagem:', error);
@ -295,27 +247,7 @@ using (true);`;
.slice(0, 16); .slice(0, 16);
}; };
const handleManualSync = async () => {
if (!supabaseConfigured) return;
setIsSyncing(true);
try {
const cloudData = await dbService.fetchFromCloud();
if (cloudData) {
setData(cloudData);
await dbService.saveData(cloudData);
showAlert('Sucesso', '✅ Dados sincronizados com a nuvem!', 'success');
} else {
// If no cloud data, maybe we should push local data?
await dbService.saveToCloud(data);
showAlert('Sucesso', '✅ Dados locais enviados para a nuvem!', 'success');
}
} catch (error) {
showAlert('Erro', '❌ Falha na sincronização. Verifique sua conexão e configurações.', 'error');
} finally {
setIsSyncing(false);
}
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm text-sm"; const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm text-sm";
@ -475,46 +407,87 @@ using (true);`;
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4"> {/* POSTGRESQL CARD */}
<div className="flex items-center gap-3 text-indigo-600"> <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4 relative overflow-hidden">
<div className="p-2 bg-indigo-50 rounded-lg"> <div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-full -mr-16 -mt-16 pointer-events-none"></div>
<Cloud size={20} /> <div className="flex items-center justify-between relative z-10">
</div> <div className="flex items-center gap-3 text-blue-600">
<h3 className="text-lg font-black text-slate-800">Sincronização Nuvem</h3> <div className="p-2 bg-blue-50 rounded-lg shadow-sm border border-blue-100">
</div> <Database size={20} />
<div className={`p-4 rounded-lg border ${supabaseConfigured ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-slate-50 border-slate-200 text-slate-500'}`}>
<div className="flex items-center gap-2 font-bold text-sm mb-1">
{supabaseConfigured ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{supabaseConfigured ? 'Conectado ao Supabase' : 'Não Conectado'}
</div>
<p className="text-xs opacity-80 leading-relaxed">
{supabaseConfigured
? 'Seus dados estão sendo salvos automaticamente na nuvem.'
: 'Para habilitar o backup na nuvem, configure as variáveis de ambiente VITE_SUPABASE_URL e VITE_SUPABASE_KEY.'}
</p>
{!supabaseConfigured && (
<div className="mt-3 text-[10px] bg-white p-2 rounded border border-slate-200 font-mono text-slate-400 break-all">
VITE_SUPABASE_URL=...<br/>
VITE_SUPABASE_KEY=...
</div> </div>
<h3 className="text-lg font-black text-slate-800">Banco de Dados</h3>
</div>
{systemStats ? (
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-full text-[10px] font-black uppercase tracking-wider">
<CheckCircle size={12} /> Online
</span>
) : (
<RefreshCw size={16} className="text-slate-300 animate-spin" />
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-3 mt-4 relative z-10">
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Tamanho em Disco</p>
<p className="text-xl font-black text-slate-800">{systemStats?.postgres?.dbSize || '--'}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Tabelas SGBD</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>
{supabaseConfigured && ( {/* MINIO STORAGE CARD */}
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-100 text-emerald-700"> <div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4 relative overflow-hidden">
<p className="text-[10px] font-black uppercase tracking-widest flex items-center gap-2"> <div className="absolute bottom-0 right-0 w-24 h-24 bg-red-50 rounded-full -mr-8 -mb-8 pointer-events-none"></div>
<CheckCircle size={14} /> Sincronização Automática Ativa <div className="flex items-center justify-between relative z-10">
</p> <div className="flex items-center gap-3 text-red-600">
<div className="p-2 bg-red-50 rounded-lg shadow-sm border border-red-100">
<Cloud size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Storage Físico</h3>
</div>
{systemStats && !systemStats.minio?.error ? (
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-full text-[10px] font-black uppercase tracking-wider">
<CheckCircle size={12} /> MinIO
</span>
) : (
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-red-100 text-red-700 rounded-full text-[10px] font-black uppercase tracking-wider">
<AlertTriangle size={12} /> Backup
</span>
)}
</div>
<div className="flex gap-4 relative z-10">
<div className="flex-1 p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Uso Total</p>
<p className="text-xl font-black text-slate-800">{systemStats?.minio?.totalSizeMB || '0.00'} <span className="text-sm font-medium text-slate-400">MB</span></p>
</div>
<div className="flex-1 p-3 bg-slate-50 rounded-xl border border-slate-100 shadow-inner">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Arquivos</p>
<p className="text-xl font-black text-slate-800">{systemStats?.minio?.totalItems || '0'}</p>
</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>
)} )}
<button
onClick={downloadSupabaseSQL}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 transition-all font-bold text-xs border border-indigo-100"
>
<FileText size={16} /> Baixar Script SQL Supabase
</button>
</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">

View File

@ -73,6 +73,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
const [cameraActive, setCameraActive] = useState(false); const [cameraActive, setCameraActive] = useState(false);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [tempPhoto, setTempPhoto] = useState<string | null>(null); const [tempPhoto, setTempPhoto] = useState<string | null>(null);
const [photoFile, setPhotoFile] = useState<File | Blob | null>(null); // Physical file for MinIO upload
const [modelsLoaded, setModelsLoaded] = useState(false); const [modelsLoaded, setModelsLoaded] = useState(false);
const [isProcessingFace, setIsProcessingFace] = useState(false); const [isProcessingFace, setIsProcessingFace] = useState(false);
@ -482,6 +483,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
try { try {
setPhotoFile(file); // Hold physical file for MinIO FormData upload
const compressed = await compressImage(file); const compressed = await compressImage(file);
setFormData(prev => ({ ...prev, photo: compressed })); setFormData(prev => ({ ...prev, photo: compressed }));
@ -561,6 +563,11 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
const savePhoto = async () => { const savePhoto = async () => {
if (tempPhoto) { if (tempPhoto) {
try { try {
// Convert base64 camera shot to physical Blob for MinIO FormData upload
const response = await fetch(tempPhoto);
const blob = await response.blob();
setPhotoFile(blob);
const compressed = await compressImage(tempPhoto); const compressed = await compressImage(tempPhoto);
setFormData(prev => ({ ...prev, photo: compressed })); setFormData(prev => ({ ...prev, photo: compressed }));
@ -599,6 +606,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
setShowModal(false); setShowModal(false);
setIsClosing(false); setIsClosing(false);
setEditingStudent(null); setEditingStudent(null);
setPhotoFile(null); // Reset MinIO physical upload file
setFormData({ setFormData({
name: '', name: '',
email: '', email: '',
@ -707,15 +715,30 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
portalPassword = rawCpfForPassword.substring(0, 6) || '123456'; portalPassword = rawCpfForPassword.substring(0, 6) || '123456';
} }
// Processar Foto em Storage (Evitar inchar o JSON) // Processar Foto via Nova Arquitetura FormData MinIO Local
let finalPhotoUrl = formData.photo || editingStudent?.photo; let finalPhotoUrl = editingStudent?.photo || '';
if (formData.photo && formData.photo.startsWith('data:image')) { if (photoFile) {
const uploadUrl = await uploadStudentPhoto(formData.photo); const uploadData = new FormData();
if (uploadUrl) { uploadData.append('photo', photoFile, 'student-avatar.webp');
finalPhotoUrl = uploadUrl;
} else { try {
showAlert('Aviso', 'Erro ao salvar a foto na nuvem. Ela foi salva localmente e pode falhar na sincronização.', 'warning'); const uploadResponse = await fetch('/api/upload/student-photo', {
method: 'POST',
body: uploadData
});
if (uploadResponse.ok) {
const resultData = await uploadResponse.json();
finalPhotoUrl = resultData.url;
} else {
showAlert('Aviso', 'Erro ao salvar a foto fisicamente no MinIO. Imagem não atualizada.', 'warning');
}
} catch (uploadError) {
console.error('Erro no upload FormData:', uploadError);
showAlert('Aviso', 'Falha de conexão no momento do upload. A foto pode não ter sido salva.', 'warning');
} }
} else if (formData.photo && !formData.photo.startsWith('data:image')) {
finalPhotoUrl = formData.photo;
} }
const studentToSave: Student = { const studentToSave: Student = {

View File

@ -5,11 +5,12 @@ async function migrarPelaWeb() {
// 1. Lendo os arquivos // 1. Lendo os arquivos
const sql = fs.readFileSync('../schema.sql', 'utf8'); const sql = fs.readFileSync('../schema.sql', 'utf8');
// Pegue o seu arquivo que já foi salvo! // Pegue o seu arquivo que já foi salvo!
const arquivos = fs.readdirSync('.'); const arquivos = fs.readdirSync('.');
const arquivoBackup = arquivos.find(a => a.startsWith('backup_supabase_') && a.endsWith('.json')); // Garante que vai pegar especificamente o arquivo migrado e limpo!
const arquivoBackup = arquivos.find(a => a.includes('_migrado.json'));
if (!arquivoBackup) { if (!arquivoBackup) {
console.log('❌ O JSON de backup não foi encontrado na pasta manager!'); console.log('❌ O JSON de backup não foi encontrado na pasta manager!');
return; return;

View File

@ -0,0 +1,147 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import * as Minio from 'minio';
import { fileURLToPath } from 'url';
// Ignora o erro de certificado SSL (Self-Signed) durante a migração local
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ============================================================================
// CONFIGURAÇÕES DO MINIO
// Substitua com as credenciais do seu Portainer/MinIO local
// ============================================================================
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'storageedu.microtecinformaticacurso.com.br';
const MINIO_PORT = parseInt(process.env.MINIO_PORT || '443');
const MINIO_USE_SSL = process.env.MINIO_USE_SSL !== 'false';
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin';
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'MiniO2026!Seguro';
const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || `https://${MINIO_ENDPOINT}`;
const minioClient = new Minio.Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
});
// ============================================================================
// FUNÇÃO AUXILIAR DE UPLOAD
// ============================================================================
async function uploadBase64ToMinio(base64String: string, bucketName: string, fileNamePrefix: string): Promise<string> {
if (!base64String || !base64String.startsWith('data:image')) {
return base64String; // Retorna como está se não for Base64 válido
}
// Extrai o MIME Type e os dados puros da string (Ex: data:image/jpeg;base64,...)
const matches = base64String.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
if (!matches || matches.length !== 3) {
return base64String;
}
const mimeType = matches[1];
const base64Data = matches[2];
const extension = mimeType.split('/')[1] || 'jpeg'; // Pega a extensão (jpeg, png, webp)
const buffer = Buffer.from(base64Data, 'base64');
const fileName = `${fileNamePrefix}-${Date.now()}.${extension}`;
// Garante que o Bucket existe no MinIO
const bucketExists = await minioClient.bucketExists(bucketName);
if (!bucketExists) {
await minioClient.makeBucket(bucketName, 'us-east-1');
console.log(`🪣 Bucket criado: ${bucketName}`);
}
// Faz o upload do Buffer
await minioClient.putObject(bucketName, fileName, buffer, buffer.length, {
'Content-Type': mimeType,
});
// Retorna a URL pública gerada
return `${MINIO_PUBLIC_URL}/${bucketName}/${fileName}`;
}
// ============================================================================
// FUNÇÃO PRINCIPAL DE MIGRAÇÃO
// ============================================================================
async function runMigration() {
console.log('🚀 Iniciando extração e migração de Base64 para MinIO...\n');
const inputFilePath = path.join(__dirname, 'backup_supabase_2026-04-19.json');
const outputFilePath = path.join(__dirname, 'backup_supabase_2026-04-19_migrado.json');
try {
// 1. Ler arquivo JSON
console.log('📦 Lendo arquivo de backup...');
const rawData = await fs.readFile(inputFilePath, 'utf-8');
const db = JSON.parse(rawData);
// 2. Migrar Logo da Escola (Tabela 'profile')
if (db.profile && db.profile.logo && db.profile.logo.startsWith('data:image')) {
console.log('🏢 Processando Logo da Escola...');
db.profile.logo = await uploadBase64ToMinio(db.profile.logo, 'escola', 'logo');
}
// 3. Migrar Fotos dos Alunos (Tabela 'students')
if (db.students && Array.isArray(db.students)) {
console.log(`🎓 Processando fotos de ${db.students.length} alunos...`);
for (let i = 0; i < db.students.length; i++) {
const student = db.students[i];
if (student.photo && student.photo.startsWith('data:image')) {
student.photo = await uploadBase64ToMinio(student.photo, 'alunos', `aluno-${student.id}`);
}
}
}
// 4. Migrar Fotos e Atestados da Frequência (Tabela 'attendance')
if (db.attendance && Array.isArray(db.attendance)) {
console.log(`📅 Processando ${db.attendance.length} registros de frequência...`);
for (let i = 0; i < db.attendance.length; i++) {
const record = db.attendance[i];
// 4.1 Foto de biometria/presença
if (record.photo && record.photo.startsWith('data:image')) {
record.photo = await uploadBase64ToMinio(record.photo, 'presenca', `presenca-${record.studentId}`);
}
// 4.2 Atestados médicos (Dentro do JSON stringificado em 'justification')
if (record.justification) {
try {
const justObj = JSON.parse(record.justification);
if (justObj.arquivo_base64 && justObj.arquivo_base64.startsWith('data:image')) {
// Upload da imagem do atestado
const newUrl = await uploadBase64ToMinio(
justObj.arquivo_base64,
'atestados',
`atestado-${record.studentId}`
);
// Substitui o Base64 pela URL limpa e recria a string JSON
justObj.arquivo_base64 = newUrl;
record.justification = JSON.stringify(justObj);
console.log(` ✅ Atestado migrado para aluno: ${record.studentId}`);
}
} catch (e) {
// Ignora se 'justification' não for um JSON válido
}
}
}
}
// 5. Salvar o novo banco de dados limpo
console.log('\n💾 Salvando novo backup JSON migrado...');
await fs.writeFile(outputFilePath, JSON.stringify(db, null, 2), 'utf-8');
console.log(`\n✅ MIGO CONCLUÍDA COM SUCESSO!`);
console.log(`✅ Arquivo gerado em: ${outputFilePath}`);
console.log(`⚠️ IMPORTANTE: Suas senhas e matrículas não foram alteradas.`);
} catch (error) {
console.error('❌ Ocorreu um erro durante a migração:', error);
}
}
runMigration();

View File

@ -121,6 +121,29 @@ app.put('/api/school-data', async (req, res) => {
} }
}); });
app.get('/api/system-stats', async (req, res) => {
try {
const dbResult = await pool.query(`
SELECT pg_size_pretty(pg_database_size(current_database())) as db_size,
(SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public') as table_count
`);
const { getMinioStats } = await import('./services/storage.js');
const minioStats = await getMinioStats();
res.json({
postgres: {
dbSize: dbResult.rows[0].db_size,
tableCount: dbResult.rows[0].table_count
},
minio: minioStats
});
} catch(e) {
console.error('System Stats Error:', e);
res.status(500).json({ error: e.message });
}
});
// ============================================================ // ============================================================
// Upload de Logo (MinIO em vez de Supabase Storage) // Upload de Logo (MinIO em vez de Supabase Storage)
// ============================================================ // ============================================================
@ -161,6 +184,42 @@ app.post('/api/upload/student-photo', upload.single('photo'), async (req, res) =
} }
}); });
// ============================================================
// Upload de Logo da Escola (MinIO)
// ============================================================
app.post('/api/upload/logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadLogo } = await import('./services/storage.js');
const url = await uploadLogo(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar logo:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================
// Upload de Imagem de Avaliação (MinIO)
// ============================================================
app.post('/api/upload/exam-image', upload.single('photo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
const { uploadExamImage } = await import('./services/storage.js');
const url = await uploadExamImage(req.file.buffer, req.file.mimetype);
return res.status(200).json({ url });
} catch (error) {
console.error('Erro ao processar imagem de avaliação:', error);
return res.status(500).json({ error: 'Erro interno.' });
}
});
// ============================================================ // ============================================================
// Formatação de Data // Formatação de Data
// ============================================================ // ============================================================

View File

@ -4,7 +4,7 @@
* Substitui todas as chamadas supabase.storage do sistema * Substitui todas as chamadas supabase.storage do sistema
* ============================================================ * ============================================================
*/ */
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { S3Client, PutObjectCommand, GetObjectCommand, ListBucketsCommand, ListObjectsV2Command } 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';
@ -95,4 +95,44 @@ export async function uploadAtestado(fileBuffer, contentType) {
return uploadFile('atestados', fileName, fileBuffer, contentType); return uploadFile('atestados', fileName, fileBuffer, contentType);
} }
export async function getMinioStats() {
try {
const data = await s3Client.send(new ListBucketsCommand({}));
const buckets = data.Buckets || [];
let totalSize = 0;
let totalItems = 0;
const bucketsInfo = await Promise.all(buckets.map(async (bucket) => {
let bucketSize = 0;
let bucketItems = 0;
try {
const objects = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket.Name }));
if (objects.Contents) {
bucketItems = objects.Contents.length;
bucketSize = objects.Contents.reduce((acc, curr) => acc + (curr.Size || 0), 0);
}
} catch (e) {} // Ignorar buckets inacessíveis
totalSize += bucketSize;
totalItems += bucketItems;
return {
name: bucket.Name,
items: bucketItems,
sizeMB: (bucketSize / (1024 * 1024)).toFixed(2)
};
}));
return {
bucketCount: buckets.length,
totalItems,
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
buckets: bucketsInfo
};
} catch (error) {
console.error('Erro ao buscar stats do MinIO:', error);
return { error: true, message: error.message };
}
}
export { s3Client }; export { s3Client };

View File

@ -71,7 +71,7 @@ export const uploadExamImage = async (file: File): Promise<string | null> => {
const formData = new FormData(); const formData = new FormData();
formData.append('photo', file); formData.append('photo', file);
const response = await fetch('/api/upload/student-photo', { const response = await fetch('/api/upload/exam-image', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });

View File

@ -78,7 +78,7 @@ export const uploadExamImage = async (file: File): Promise<string | null> => {
const formData = new FormData(); const formData = new FormData();
formData.append('photo', file); formData.append('photo', file);
const response = await fetch('/api/upload/student-photo', { const response = await fetch('/api/upload/exam-image', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });