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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// 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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue