feat: migração completa storage MinIO, telemetria real de DB e correção de frequência
This commit is contained in:
parent
1d64650895
commit
715304fee5
File diff suppressed because one or more lines are too long
|
|
@ -152,9 +152,10 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
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 =>
|
||||
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 || [])];
|
||||
|
|
@ -165,7 +166,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
type: 'absence',
|
||||
justification: absenceJustification,
|
||||
justificationAccepted: true,
|
||||
verified: true
|
||||
verified: true,
|
||||
lessonId: lesson.id as any
|
||||
};
|
||||
} else {
|
||||
const newAbsence: Attendance = {
|
||||
|
|
@ -176,7 +178,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
verified: true,
|
||||
type: 'absence',
|
||||
justification: absenceJustification,
|
||||
justificationAccepted: true
|
||||
justificationAccepted: true,
|
||||
...(lesson ? { lessonId: lesson.id } : {}) as any
|
||||
};
|
||||
updatedAttendance.push(newAbsence);
|
||||
}
|
||||
|
|
@ -352,11 +355,43 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
);
|
||||
}
|
||||
|
||||
return classStudents.map(student => {
|
||||
const studentAttendance = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id);
|
||||
const presences = studentAttendance.filter(a => a.type === 'presence' || a.type !== 'absence').length;
|
||||
const absences = studentAttendance.filter(a => a.type === 'absence').length;
|
||||
const justified = studentAttendance.filter(a => a.type === 'absence' && a.justificationAccepted).length;
|
||||
const studentActualRecords = (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 deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -431,65 +466,51 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
const actualRecords = (data.attendance || [])
|
||||
.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');
|
||||
|
||||
const virtualRecords: any[] = [];
|
||||
const matchedActualRecordIds = new Set<string>();
|
||||
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
|
||||
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 lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
|
||||
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); // 30 mins before
|
||||
|
||||
// Lógica de "Casamento" refinada para evitar duplicidade
|
||||
const matchingRecord = actualRecords.find(a => {
|
||||
// Match forte se já tiver lessonId (registros criados a partir daqui)
|
||||
let record = actualRecords.find(a => {
|
||||
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;
|
||||
|
||||
// Match por Janela de Tempo (Biometria): entre 30 min antes da aula até o fim da aula
|
||||
const recordTime = new Date(a.date);
|
||||
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({
|
||||
id: `v-${lesson.id}`,
|
||||
studentId: selectedStudent.id,
|
||||
classId: selectedClass.id,
|
||||
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
|
||||
type: isFinished ? 'absence' : 'awaiting',
|
||||
isVirtual: true,
|
||||
lessonId: lesson.id,
|
||||
awaiting: !isFinished
|
||||
});
|
||||
}
|
||||
} else {
|
||||
(matchingRecord as any).lessonId = lesson.id;
|
||||
matchedActualRecordIds.add(matchingRecord.id);
|
||||
if (!record && now >= presenceStartWindow) {
|
||||
const isFinished = now > lessonEnd;
|
||||
record = {
|
||||
id: `v-${lesson.id}`,
|
||||
studentId: selectedStudent.id,
|
||||
classId: selectedClass.id,
|
||||
date: `${lesson.date}T${lesson.startTime || '00:00'}:00`,
|
||||
type: isFinished ? 'absence' : 'awaiting',
|
||||
isVirtual: true,
|
||||
lessonId: lesson.id,
|
||||
awaiting: !isFinished
|
||||
};
|
||||
}
|
||||
|
||||
if (record) {
|
||||
tableRows.push({ lesson, record });
|
||||
}
|
||||
});
|
||||
|
||||
// Filtrar os records reais para evitar duplicidade na lista caso haja lixo no banco
|
||||
// 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;
|
||||
});
|
||||
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());
|
||||
|
||||
const studentRecords = [...uniqueActualRecords, ...virtualRecords]
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
if (studentRecords.length === 0) {
|
||||
if (tableRows.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-slate-400">
|
||||
<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;
|
||||
const absences = studentRecords.filter(a => a.type === 'absence').length;
|
||||
const justified = studentRecords.filter(a => a.type === 'absence' && a.justificationAccepted).length;
|
||||
let presences = 0;
|
||||
let absences = 0;
|
||||
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 (
|
||||
<>
|
||||
|
|
@ -516,7 +546,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
<AlertCircle size={14} /> {justified} Justificadas
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -536,16 +566,9 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{studentRecords.map(record => {
|
||||
{tableRows.map(({lesson, record}) => {
|
||||
const recordDate = new Date(record.date);
|
||||
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 justAttachment: string | null = null;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
|||
} catch (error: any) {
|
||||
console.error(error);
|
||||
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 {
|
||||
setIsUploading(false);
|
||||
if (event.target) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ const Settings: React.FC<SettingsProps> = ({ data, updateData, setData }) => {
|
|||
|
||||
const [activeTab, setActiveTab] = useState<'perfil' | 'monitoramento'>('perfil');
|
||||
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(() => {
|
||||
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 = () => {
|
||||
setIsClosing(true);
|
||||
|
|
@ -253,27 +219,13 @@ using (true);`;
|
|||
|
||||
const compressedFile = await imageCompression(file, options);
|
||||
|
||||
let logoUrl = '';
|
||||
|
||||
// Try to upload to Supabase if configured
|
||||
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);
|
||||
});
|
||||
const url = await uploadLogo(compressedFile);
|
||||
if (!url) {
|
||||
throw new Error("Falha ao obter a URL da logo após o upload");
|
||||
}
|
||||
|
||||
setGlobalLogo(logoUrl);
|
||||
updateData({ logo: logoUrl });
|
||||
setGlobalLogo(url);
|
||||
updateData({ logo: url });
|
||||
showAlert('Sucesso', 'Logo atualizada com sucesso!', 'success');
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer upload da imagem:', error);
|
||||
|
|
@ -295,27 +247,7 @@ using (true);`;
|
|||
.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";
|
||||
|
||||
|
|
@ -475,46 +407,87 @@ using (true);`;
|
|||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
|
||||
<div className="flex items-center gap-3 text-indigo-600">
|
||||
<div className="p-2 bg-indigo-50 rounded-lg">
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-slate-800">Sincronização Nuvem</h3>
|
||||
</div>
|
||||
|
||||
<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=...
|
||||
{/* POSTGRESQL CARD */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-50 rounded-full -mr-16 -mt-16 pointer-events-none"></div>
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center gap-3 text-blue-600">
|
||||
<div className="p-2 bg-blue-50 rounded-lg shadow-sm border border-blue-100">
|
||||
<Database size={20} />
|
||||
</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 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 && (
|
||||
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-100 text-emerald-700">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
|
||||
<CheckCircle size={14} /> Sincronização Automática Ativa
|
||||
</p>
|
||||
{/* MINIO STORAGE CARD */}
|
||||
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4 relative overflow-hidden">
|
||||
<div className="absolute bottom-0 right-0 w-24 h-24 bg-red-50 rounded-full -mr-8 -mb-8 pointer-events-none"></div>
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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 className="bg-white p-6 rounded-xl border border-slate-200 shadow-xl space-y-4">
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
const [cameraActive, setCameraActive] = useState(false);
|
||||
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
|
||||
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 [isProcessingFace, setIsProcessingFace] = useState(false);
|
||||
|
||||
|
|
@ -482,6 +483,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
setPhotoFile(file); // Hold physical file for MinIO FormData upload
|
||||
const compressed = await compressImage(file);
|
||||
setFormData(prev => ({ ...prev, photo: compressed }));
|
||||
|
||||
|
|
@ -561,6 +563,11 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
const savePhoto = async () => {
|
||||
if (tempPhoto) {
|
||||
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);
|
||||
setFormData(prev => ({ ...prev, photo: compressed }));
|
||||
|
||||
|
|
@ -599,6 +606,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
setShowModal(false);
|
||||
setIsClosing(false);
|
||||
setEditingStudent(null);
|
||||
setPhotoFile(null); // Reset MinIO physical upload file
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
|
|
@ -707,15 +715,30 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
|||
portalPassword = rawCpfForPassword.substring(0, 6) || '123456';
|
||||
}
|
||||
|
||||
// Processar Foto em Storage (Evitar inchar o JSON)
|
||||
let finalPhotoUrl = formData.photo || editingStudent?.photo;
|
||||
if (formData.photo && formData.photo.startsWith('data:image')) {
|
||||
const uploadUrl = await uploadStudentPhoto(formData.photo);
|
||||
if (uploadUrl) {
|
||||
finalPhotoUrl = uploadUrl;
|
||||
} else {
|
||||
showAlert('Aviso', 'Erro ao salvar a foto na nuvem. Ela foi salva localmente e pode falhar na sincronização.', 'warning');
|
||||
// Processar Foto via Nova Arquitetura FormData MinIO Local
|
||||
let finalPhotoUrl = editingStudent?.photo || '';
|
||||
if (photoFile) {
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('photo', photoFile, 'student-avatar.webp');
|
||||
|
||||
try {
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ async function migrarPelaWeb() {
|
|||
|
||||
// 1. Lendo os arquivos
|
||||
const sql = fs.readFileSync('../schema.sql', 'utf8');
|
||||
|
||||
|
||||
// Pegue o seu arquivo que já foi salvo!
|
||||
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) {
|
||||
console.log('❌ O JSON de backup não foi encontrado na pasta manager!');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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)
|
||||
// ============================================================
|
||||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 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_PORT = process.env.MINIO_PORT || '9000';
|
||||
|
|
@ -95,4 +95,44 @@ export async function uploadAtestado(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 };
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export const uploadExamImage = async (file: File): Promise<string | null> => {
|
|||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
const response = await fetch('/api/upload/student-photo', {
|
||||
const response = await fetch('/api/upload/exam-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export const uploadExamImage = async (file: File): Promise<string | null> => {
|
|||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
const response = await fetch('/api/upload/student-photo', {
|
||||
const response = await fetch('/api/upload/exam-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue