feat: implement soft delete for exams and update grade calculation to arithmetic mean across manager and portal
This commit is contained in:
parent
9e44ce0712
commit
488051673a
|
|
@ -17,6 +17,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [duplicatingExam, setDuplicatingExam] = useState<Exam | null>(null);
|
const [duplicatingExam, setDuplicatingExam] = useState<Exam | null>(null);
|
||||||
const [targetClassId, setTargetClassId] = useState('');
|
const [targetClassId, setTargetClassId] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'ativos' | 'lixeira'>('ativos');
|
||||||
const { showAlert, showConfirm } = useDialog();
|
const { showAlert, showConfirm } = useDialog();
|
||||||
|
|
||||||
const normalizePhotoUrl = (url?: string) => {
|
const normalizePhotoUrl = (url?: string) => {
|
||||||
|
|
@ -32,8 +33,9 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
const exams = data.exams || [];
|
const exams = data.exams || [];
|
||||||
|
|
||||||
const filteredExams = exams.filter(exam =>
|
const filteredExams = exams.filter(exam =>
|
||||||
exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
(activeTab === 'ativos' ? !exam.isDeleted : !!exam.isDeleted) &&
|
||||||
data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase())
|
(exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStartCreate = () => {
|
const handleStartCreate = () => {
|
||||||
|
|
@ -69,17 +71,24 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
const handleDeleteExam = (examId: string) => {
|
const handleDeleteExam = (examId: string) => {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
'Excluir Avaliação',
|
'Mover para Lixeira',
|
||||||
'Tem certeza que deseja excluir esta avaliação? Esta ação não pode ser desfeita e notas vinculadas no boletim perderão o vínculo.',
|
'Tem certeza que deseja mover esta avaliação para a lixeira? Ela será ocultada para os alunos, mas as notas no boletim continuarão intactas.',
|
||||||
() => {
|
() => {
|
||||||
const updatedExams = exams.filter(e => e.id !== examId);
|
const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: true } : e);
|
||||||
updateData({ exams: updatedExams });
|
updateData({ exams: updatedExams });
|
||||||
dbService.saveData({ ...data, exams: updatedExams });
|
dbService.saveData({ ...data, exams: updatedExams });
|
||||||
showAlert('Sucesso', 'Avaliação excluída com sucesso.', 'success');
|
showAlert('Sucesso', 'Avaliação movida para a lixeira.', 'success');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRestoreExam = (examId: string) => {
|
||||||
|
const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: false } : e);
|
||||||
|
updateData({ exams: updatedExams });
|
||||||
|
dbService.saveData({ ...data, exams: updatedExams });
|
||||||
|
showAlert('Sucesso', 'Avaliação reativada.', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
const handleDuplicateExam = () => {
|
const handleDuplicateExam = () => {
|
||||||
if (!duplicatingExam || !targetClassId) return;
|
if (!duplicatingExam || !targetClassId) return;
|
||||||
|
|
||||||
|
|
@ -515,6 +524,20 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex bg-slate-100 p-1 rounded-xl shrink-0 self-start md:self-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('ativos')}
|
||||||
|
className={`px-4 py-2.5 rounded-lg text-sm font-black transition-all ${activeTab === 'ativos' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Ativos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('lixeira')}
|
||||||
|
className={`px-4 py-2.5 rounded-lg text-sm font-black transition-all ${activeTab === 'lixeira' ? 'bg-white text-red-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Lixeira
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||||
<input
|
<input
|
||||||
|
|
@ -554,8 +577,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
{filteredExams.map(exam => {
|
{filteredExams.map(exam => {
|
||||||
const classObj = data.classes.find(c => c.id === exam.classId);
|
const classObj = data.classes.find(c => c.id === exam.classId);
|
||||||
return (
|
return (
|
||||||
<div key={exam.id} className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow relative overflow-hidden group">
|
<div key={exam.id} className={`rounded-2xl p-6 shadow-sm border transition-shadow relative overflow-hidden group ${exam.isDeleted ? 'bg-slate-50 border-slate-200 opacity-80' : 'bg-white border-slate-100 hover:shadow-md'}`}>
|
||||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
|
<div className={`absolute top-0 left-0 w-1.5 h-full rounded-l-2xl ${exam.isDeleted ? 'bg-slate-400' : 'bg-indigo-500'}`}></div>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
|
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
|
||||||
|
|
@ -569,9 +592,9 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 mt-1 ${exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
|
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 mt-1 ${exam.isDeleted ? 'bg-slate-200 text-slate-500' : (exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700')
|
||||||
}`}>
|
}`}>
|
||||||
{exam.status === 'published' ? 'Publicada' : 'Rascunho'}
|
{exam.isDeleted ? 'Excluída' : (exam.status === 'published' ? 'Publicada' : 'Rascunho')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2 mb-6">
|
||||||
|
|
@ -612,6 +635,15 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-100 pt-4 flex items-center justify-between">
|
<div className="border-t border-slate-100 pt-4 flex items-center justify-between">
|
||||||
|
{exam.isDeleted ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestoreExam(exam.id)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} /> Reativar
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleRetake(exam.id)}
|
onClick={() => handleToggleRetake(exam.id)}
|
||||||
|
|
@ -640,7 +672,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteExam(exam.id)}
|
onClick={() => handleDeleteExam(exam.id)}
|
||||||
className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
|
className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
|
||||||
title="Excluir"
|
title="Mover para Lixeira"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -649,8 +681,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
onClick={() => handleEditExam(exam)}
|
onClick={() => handleEditExam(exam)}
|
||||||
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform"
|
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform"
|
||||||
>
|
>
|
||||||
Editar {exam.evaluationType === 'activity' ? 'Atividade' : 'Prova'}
|
Editar <ArrowLeft size={16} className="rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -259,17 +259,18 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
|
|
||||||
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
|
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
|
||||||
const periodSums: number[] = [];
|
const periodAvgs: number[] = [];
|
||||||
|
|
||||||
Object.values(subjectPeriods).forEach((examValues: any) => {
|
Object.values(subjectPeriods).forEach((examValues: any) => {
|
||||||
const sum: number = Object.values(examValues).reduce<number>((a, b: any) => a + (b !== '' ? Number(b) : 0), 0);
|
const validValues = Object.values(examValues).filter(v => v !== '');
|
||||||
if (Object.values(examValues).some(v => v !== '')) {
|
if (validValues.length > 0) {
|
||||||
periodSums.push(sum);
|
const sum: number = validValues.reduce<number>((a, b: any) => a + Number(b), 0);
|
||||||
|
periodAvgs.push(sum / validValues.length);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (periodSums.length > 0) {
|
if (periodAvgs.length > 0) {
|
||||||
const subjectAvg = periodSums.reduce((a, b) => a + b, 0) / periodSums.length;
|
const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length;
|
||||||
totalSum += subjectAvg;
|
totalSum += subjectAvg;
|
||||||
totalCount++;
|
totalCount++;
|
||||||
}
|
}
|
||||||
|
|
@ -292,15 +293,23 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
subjectsWithGrades.forEach(subId => {
|
subjectsWithGrades.forEach(subId => {
|
||||||
const subGrades = studentGradesList.filter(g => g.subjectId === subId);
|
const subGrades = studentGradesList.filter(g => g.subjectId === subId);
|
||||||
|
|
||||||
const periodSums: Record<string, number> = {};
|
const periodValues: Record<string, number[]> = {};
|
||||||
subGrades.forEach(g => {
|
subGrades.forEach(g => {
|
||||||
periodSums[g.period] = (periodSums[g.period] || 0) + g.value;
|
if (!periodValues[g.period]) periodValues[g.period] = [];
|
||||||
|
periodValues[g.period].push(g.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const periodsCount = Object.keys(periodSums).length;
|
const periodAvgs: number[] = [];
|
||||||
if (periodsCount > 0) {
|
Object.values(periodValues).forEach(values => {
|
||||||
const totalSum = Object.values(periodSums).reduce((a, b) => a + b, 0);
|
if (values.length > 0) {
|
||||||
subjectAverages.push(totalSum / periodsCount);
|
const sum = values.reduce((a, b) => a + b, 0);
|
||||||
|
periodAvgs.push(sum / values.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (periodAvgs.length > 0) {
|
||||||
|
const totalSum = periodAvgs.reduce((a, b) => a + b, 0);
|
||||||
|
subjectAverages.push(totalSum / periodAvgs.length);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -611,9 +620,14 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
<div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500">
|
<div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500">
|
||||||
MÉDIA: {(() => {
|
MÉDIA: {(() => {
|
||||||
const subjectGrades = studentGrades[subject.id] || {};
|
const subjectGrades = studentGrades[subject.id] || {};
|
||||||
const pSums: number[] = Object.values(subjectGrades).map((exVals: any) => Object.values(exVals).reduce<number>((a, b: any) => a + (b !== '' ? Number(b) : 0), 0));
|
const pAvgs: number[] = [];
|
||||||
const valid = pSums.filter(s => s > 0);
|
Object.values(subjectGrades).forEach((exVals: any) => {
|
||||||
return valid.length > 0 ? (valid.reduce((a, b) => a + b, 0) / valid.length).toFixed(1) : '0.0';
|
const validValues = Object.values(exVals).filter(v => v !== '');
|
||||||
|
if (validValues.length > 0) {
|
||||||
|
pAvgs.push(validValues.reduce<number>((a, b: any) => a + Number(b), 0) / validValues.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return pAvgs.length > 0 ? (pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length).toFixed(1) : '0.0';
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -626,13 +640,16 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
|
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
|
||||||
);
|
);
|
||||||
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
|
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
|
||||||
const periodSum: number = Object.values(periodGrades).reduce<number>((a, b: any) => a + (b !== '' ? Number(b) : 0), 0);
|
const validPeriodValues = Object.values(periodGrades).filter(v => v !== '');
|
||||||
|
const periodAvg: number = validPeriodValues.length > 0
|
||||||
|
? validPeriodValues.reduce<number>((a, b: any) => a + Number(b), 0) / validPeriodValues.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={period.id} className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm space-y-3 relative">
|
<div key={period.id} className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm space-y-3 relative">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2 mb-2">
|
<div className="flex items-center justify-between border-b border-slate-100 pb-2 mb-2">
|
||||||
<label className="block text-xs font-black text-slate-700 uppercase tracking-widest">{period.name}</label>
|
<label className="block text-xs font-black text-slate-700 uppercase tracking-widest">{period.name}</label>
|
||||||
<span className="text-[10px] font-bold bg-slate-100 text-slate-600 px-2 py-1 rounded-md">Total: {periodSum.toFixed(1)}</span>
|
<span className="text-[10px] font-bold bg-slate-100 text-slate-600 px-2 py-1 rounded-md">Média: {periodAvg.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{linkedExams.length > 0 ? (
|
{linkedExams.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ const App = () => {
|
||||||
saveTimeoutRef.current = setTimeout(async () => {
|
saveTimeoutRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dbService.saveToCloud(data);
|
const result = await dbService.saveToCloud(data);
|
||||||
if (result.success) {
|
if (result.success && result.lastUpdated) {
|
||||||
|
// Sincroniza o timestamp local com o do servidor para evitar conflitos no polling
|
||||||
|
setData(prev => ({ ...prev, lastUpdated: result.lastUpdated }));
|
||||||
setSyncStatus('saved');
|
setSyncStatus('saved');
|
||||||
} else if (result.reason === 'newer_version') {
|
} else if (result.reason === 'newer_version') {
|
||||||
setSyncStatus('conflict');
|
setSyncStatus('conflict');
|
||||||
|
|
|
||||||
|
|
@ -202,10 +202,14 @@ app.put('/api/school-data', async (req, res) => {
|
||||||
// Sincronização em tempo real (JSON -> Relacional)
|
// Sincronização em tempo real (JSON -> Relacional)
|
||||||
syncJsonToRelationalTables().catch(err => console.error('[Real-time Sync] Erro:', err.message));
|
syncJsonToRelationalTables().catch(err => console.error('[Real-time Sync] Erro:', err.message));
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Dados salvos com sucesso',
|
||||||
|
lastUpdated: schoolData.lastUpdated
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao salvar school_data:', error);
|
console.error('Erro ao salvar school-data:', error);
|
||||||
res.status(500).json({ success: false, reason: 'error' });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -978,13 +982,21 @@ app.post('/api/excluir_cobranca', async (req, res) => {
|
||||||
if (!isSinglePayment) {
|
if (!isSinglePayment) {
|
||||||
const asaasTargetId = formatInstallmentId(id);
|
const asaasTargetId = formatInstallmentId(id);
|
||||||
const resp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetId}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
|
const resp = await fetch(`${ASAAS_BASE_URL}/v3/installments/${asaasTargetId}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
|
||||||
if (resp.ok) addLog('Asaas', 'Exclusão Parcelamento OK', { id });
|
if (resp.ok) {
|
||||||
|
addLog('Asaas', 'Exclusão Parcelamento OK', { id: asaasTargetId });
|
||||||
|
// Exclusão imediata no SQL local para evitar que reapareça na UI antes do webhook
|
||||||
|
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_installment_id = $1', [asaasTargetId]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
|
const resp = await fetch(`${ASAAS_BASE_URL}/v3/payments/${id}`, { method: 'DELETE', headers: { 'access_token': process.env.ASAAS_API_KEY } });
|
||||||
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
|
if (!resp.ok) { const e = await resp.json().catch(() => ({})); return res.status(400).json({ error: e.errors?.[0]?.description || 'Falha Asaas' }); }
|
||||||
|
|
||||||
|
// Exclusão imediata no SQL local
|
||||||
|
await pool.query('DELETE FROM alunos_cobrancas WHERE asaas_payment_id = $1', [id]);
|
||||||
|
addLog('Asaas', 'Exclusão Cobrança OK', { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ message: 'Excluído no Asaas (Aguardando Webhook)' });
|
return res.status(200).json({ message: 'Excluído no Asaas e na base local' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Exclusão] Erro:', error);
|
console.error('[Exclusão] Erro:', error);
|
||||||
return res.status(500).json({ error: 'Erro interno.' });
|
return res.status(500).json({ error: 'Erro interno.' });
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,13 @@ export async function syncJsonToRelationalTables() {
|
||||||
|
|
||||||
// 1. Sincronizar Cursos
|
// 1. Sincronizar Cursos
|
||||||
if (data.courses && Array.isArray(data.courses)) {
|
if (data.courses && Array.isArray(data.courses)) {
|
||||||
|
const courseIds = data.courses.map(c => c.id).filter(Boolean);
|
||||||
|
if (courseIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM cursos WHERE id != ALL($1)', [courseIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM cursos');
|
||||||
|
}
|
||||||
|
|
||||||
for (const c of data.courses) {
|
for (const c of data.courses) {
|
||||||
if (!c.id || !c.name) continue;
|
if (!c.id || !c.name) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -369,8 +376,15 @@ export async function syncJsonToRelationalTables() {
|
||||||
// Garantir colunas de refação em provas
|
// Garantir colunas de refação em provas
|
||||||
await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE');
|
await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE');
|
||||||
|
|
||||||
// 1. Sincronizar Disciplinas (Subjects)
|
// 2. Sincronizar Disciplinas (Subjects)
|
||||||
if (data.subjects && Array.isArray(data.subjects)) {
|
if (data.subjects && Array.isArray(data.subjects)) {
|
||||||
|
const subIds = data.subjects.map(s => s.id).filter(Boolean);
|
||||||
|
if (subIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM disciplinas WHERE id != ALL($1)', [subIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM disciplinas');
|
||||||
|
}
|
||||||
|
|
||||||
for (const sub of data.subjects) {
|
for (const sub of data.subjects) {
|
||||||
if (!sub.id || !sub.name) continue;
|
if (!sub.id || !sub.name) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -384,6 +398,13 @@ export async function syncJsonToRelationalTables() {
|
||||||
|
|
||||||
// 3. Sincronizar Períodos (Bimestres)
|
// 3. Sincronizar Períodos (Bimestres)
|
||||||
if (data.periods && Array.isArray(data.periods)) {
|
if (data.periods && Array.isArray(data.periods)) {
|
||||||
|
const periodIds = data.periods.map(p => p.id).filter(Boolean);
|
||||||
|
if (periodIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM periodos WHERE id != ALL($1)', [periodIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM periodos');
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of data.periods) {
|
for (const p of data.periods) {
|
||||||
if (!p.id || !p.name) continue;
|
if (!p.id || !p.name) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -397,6 +418,13 @@ export async function syncJsonToRelationalTables() {
|
||||||
|
|
||||||
// 4. Sincronizar Turmas
|
// 4. Sincronizar Turmas
|
||||||
if (data.classes && Array.isArray(data.classes)) {
|
if (data.classes && Array.isArray(data.classes)) {
|
||||||
|
const classIds = data.classes.map(t => t.id).filter(Boolean);
|
||||||
|
if (classIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM turmas WHERE id != ALL($1)', [classIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM turmas');
|
||||||
|
}
|
||||||
|
|
||||||
for (const t of data.classes) {
|
for (const t of data.classes) {
|
||||||
if (!t.id || !t.name) continue;
|
if (!t.id || !t.name) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -412,8 +440,15 @@ export async function syncJsonToRelationalTables() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Sincronizar Alunos (Mapeamento Completo de Campos)
|
// 5. Sincronizar Alunos
|
||||||
if (data.students && Array.isArray(data.students)) {
|
if (data.students && Array.isArray(data.students)) {
|
||||||
|
const studentIds = data.students.map(s => s.id).filter(Boolean);
|
||||||
|
if (studentIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM alunos WHERE id != ALL($1)', [studentIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM alunos');
|
||||||
|
}
|
||||||
|
|
||||||
for (const s of data.students) {
|
for (const s of data.students) {
|
||||||
if (!s.id || !s.name) continue;
|
if (!s.id || !s.name) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -445,8 +480,15 @@ export async function syncJsonToRelationalTables() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Sincronizar Provas (Corrigindo vínculo com disciplinas)
|
// 6. Sincronizar Provas
|
||||||
if (data.exams && Array.isArray(data.exams)) {
|
if (data.exams && Array.isArray(data.exams)) {
|
||||||
|
const examIds = data.exams.map(e => e.id).filter(Boolean);
|
||||||
|
if (examIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM provas WHERE id != ALL($1)', [examIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM provas');
|
||||||
|
}
|
||||||
|
|
||||||
for (const e of data.exams) {
|
for (const e of data.exams) {
|
||||||
if (!e.id || !e.title) continue;
|
if (!e.id || !e.title) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
@ -461,8 +503,15 @@ export async function syncJsonToRelationalTables() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Sincronizar Frequências (Baseado na estrutura real do JSON)
|
// 7. Sincronizar Frequências
|
||||||
if (data.attendance && Array.isArray(data.attendance)) {
|
if (data.attendance && Array.isArray(data.attendance)) {
|
||||||
|
const attIds = data.attendance.map(f => f.id).filter(Boolean);
|
||||||
|
if (attIds.length > 0) {
|
||||||
|
await client.query('DELETE FROM frequencias WHERE id != ALL($1)', [attIds]);
|
||||||
|
} else {
|
||||||
|
await client.query('DELETE FROM frequencias');
|
||||||
|
}
|
||||||
|
|
||||||
for (const f of data.attendance) {
|
for (const f of data.attendance) {
|
||||||
if (!f.id || !f.studentId || !f.classId) continue;
|
if (!f.id || !f.studentId || !f.classId) continue;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export const dbService = {
|
||||||
// Em vez de: supabase.from('school_data').upsert(...)
|
// Em vez de: supabase.from('school_data').upsert(...)
|
||||||
// Agora usa: fetch('/api/school-data', { method: 'PUT' })
|
// Agora usa: fetch('/api/school-data', { method: 'PUT' })
|
||||||
// ============================================================
|
// ============================================================
|
||||||
saveToCloud: async (data: SchoolData): Promise<{ success: boolean; reason?: 'newer_version' | 'error' }> => {
|
saveToCloud: async (data: SchoolData): Promise<{ success: boolean; reason?: 'newer_version' | 'error'; lastUpdated?: string }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/school-data', {
|
const response = await fetch('/api/school-data', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|
@ -263,8 +263,9 @@ export const dbService = {
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
if (response.status === 409 && result.reason === 'newer_version') {
|
if (response.status === 409 && result.reason === 'newer_version') {
|
||||||
console.warn("Servidor tem versão mais nova. Abortando save.");
|
console.warn("Servidor tem versão mais nova. Abortando save.");
|
||||||
return { success: false, reason: 'newer_version' };
|
return { success: false, reason: 'newer_version' };
|
||||||
|
|
@ -272,7 +273,10 @@ export const dbService = {
|
||||||
throw new Error('Erro ao salvar');
|
throw new Error('Erro ao salvar');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return {
|
||||||
|
success: true,
|
||||||
|
lastUpdated: result.lastUpdated
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Erro ao salvar na nuvem:", e);
|
console.error("Erro ao salvar na nuvem:", e);
|
||||||
return { success: false, reason: 'error' };
|
return { success: false, reason: 'error' };
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,7 @@ export interface Exam {
|
||||||
allowRetake?: boolean;
|
allowRetake?: boolean;
|
||||||
evaluationType?: 'exam' | 'activity';
|
evaluationType?: 'exam' | 'activity';
|
||||||
maxScore?: number;
|
maxScore?: number;
|
||||||
|
isDeleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchoolData {
|
export interface SchoolData {
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
|
||||||
if (!student) return res.json({ exams: [], submissions: [] });
|
if (!student) return res.json({ exams: [], submissions: [] });
|
||||||
|
|
||||||
const exams = (schoolData.exams || [])
|
const exams = (schoolData.exams || [])
|
||||||
.filter(e => e.status === 'published' && e.classId === student.classId)
|
.filter(e => e.status === 'published' && e.classId === student.classId && !e.isDeleted)
|
||||||
.map(e => ({
|
.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
questions: e.questions.map(q => ({
|
questions: e.questions.map(q => ({
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,43 @@ export default function Notas() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// General average logic
|
// General average logic
|
||||||
const validGrades = grades.filter(g => g.value > 0);
|
const calculateGeneralAverage = () => {
|
||||||
const totalAvg = displaySubjects.length > 0 && validGrades.length > 0
|
if (displaySubjects.length === 0 || grades.length === 0) return 0;
|
||||||
? validGrades.reduce((s, g) => s + g.value, 0) / validGrades.length
|
|
||||||
: 0;
|
let totalSum = 0;
|
||||||
|
let totalCount = 0;
|
||||||
|
|
||||||
|
displaySubjects.forEach(subject => {
|
||||||
|
const subjectId = typeof subject === 'string' ? subject : subject.id;
|
||||||
|
const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId));
|
||||||
|
if (subjectGrades.length === 0) return;
|
||||||
|
|
||||||
|
const periodValues: Record<string, number[]> = {};
|
||||||
|
subjectGrades.forEach(g => {
|
||||||
|
const periodKey = String(g.periodName || g.period);
|
||||||
|
if (!periodValues[periodKey]) periodValues[periodKey] = [];
|
||||||
|
periodValues[periodKey].push(g.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const periodAvgs: number[] = [];
|
||||||
|
Object.values(periodValues).forEach(values => {
|
||||||
|
if (values.length > 0) {
|
||||||
|
const sum = values.reduce((a, b) => a + b, 0);
|
||||||
|
periodAvgs.push(sum / values.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (periodAvgs.length > 0) {
|
||||||
|
const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length;
|
||||||
|
totalSum += subjectAvg;
|
||||||
|
totalCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return totalCount > 0 ? totalSum / totalCount : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalAvg = calculateGeneralAverage();
|
||||||
|
|
||||||
const getGradeColor = (value: number, maxScore: number = 10) => {
|
const getGradeColor = (value: number, maxScore: number = 10) => {
|
||||||
const percentage = (value / maxScore) * 10;
|
const percentage = (value / maxScore) * 10;
|
||||||
|
|
@ -134,10 +167,30 @@ export default function Notas() {
|
||||||
const subjectName = typeof subject === 'string' ? subject : subject.name;
|
const subjectName = typeof subject === 'string' ? subject : subject.name;
|
||||||
const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId));
|
const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId));
|
||||||
|
|
||||||
|
// Calculate Subject Average
|
||||||
|
const periodValues: Record<string, number[]> = {};
|
||||||
|
subjectGrades.forEach(g => {
|
||||||
|
const periodKey = String(g.periodName || g.period);
|
||||||
|
if (!periodValues[periodKey]) periodValues[periodKey] = [];
|
||||||
|
periodValues[periodKey].push(g.value);
|
||||||
|
});
|
||||||
|
const pAvgs: number[] = [];
|
||||||
|
Object.values(periodValues).forEach(values => {
|
||||||
|
if (values.length > 0) {
|
||||||
|
pAvgs.push(values.reduce((a, b) => a + b, 0) / values.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const subjectAvg = pAvgs.length > 0 ? pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
|
<div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
|
||||||
<div style={{ padding: '1.5rem', background: 'var(--color-surface-light)', borderBottom: '1px solid var(--glass-border)' }}>
|
<div style={{ padding: '1.5rem', background: 'var(--color-surface-light)', borderBottom: '1px solid var(--glass-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-primary)' }}>{subjectName}</h2>
|
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-primary)' }}>{subjectName}</h2>
|
||||||
|
{subjectAvg !== null && (
|
||||||
|
<div style={{ padding: '4px 12px', borderRadius: '8px', background: getBgColor(subjectAvg), color: getGradeColor(subjectAvg), fontWeight: 800, fontSize: '0.85rem' }}>
|
||||||
|
MÉDIA: {subjectAvg.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
|
@ -145,14 +198,14 @@ export default function Notas() {
|
||||||
const periodGrades = subjectGrades.filter(g => String(g.periodName || g.period) === String(period));
|
const periodGrades = subjectGrades.filter(g => String(g.periodName || g.period) === String(period));
|
||||||
if (periodGrades.length === 0) return null;
|
if (periodGrades.length === 0) return null;
|
||||||
|
|
||||||
const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0);
|
const periodAvg = periodGrades.length > 0 ? periodGrades.reduce((sum, g) => sum + g.value, 0) / periodGrades.length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={period} style={{ marginBottom: '1.5rem' }}>
|
<div key={period} style={{ marginBottom: '1.5rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem', borderBottom: '2px solid var(--glass-border)', paddingBottom: '0.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem', borderBottom: '2px solid var(--glass-border)', paddingBottom: '0.5rem' }}>
|
||||||
<h3 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-secondary)' }}>{period}</h3>
|
<h3 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-secondary)' }}>{period}</h3>
|
||||||
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--color-text)' }}>
|
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--color-text)' }}>
|
||||||
Total do Período: <span style={{ color: getGradeColor(periodTotal, 10) }}>{periodTotal.toFixed(1)}</span>
|
Média do Período: <span style={{ color: getGradeColor(periodAvg, 10) }}>{periodAvg.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue