feat: implement soft delete for exams and update grade calculation to arithmetic mean across manager and portal

This commit is contained in:
Sidney 2026-05-05 21:44:56 -03:00
parent 9e44ce0712
commit 488051673a
9 changed files with 256 additions and 84 deletions

View File

@ -17,6 +17,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const [isUploading, setIsUploading] = useState(false);
const [duplicatingExam, setDuplicatingExam] = useState<Exam | null>(null);
const [targetClassId, setTargetClassId] = useState('');
const [activeTab, setActiveTab] = useState<'ativos' | 'lixeira'>('ativos');
const { showAlert, showConfirm } = useDialog();
const normalizePhotoUrl = (url?: string) => {
@ -32,8 +33,9 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const exams = data.exams || [];
const filteredExams = exams.filter(exam =>
exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase())
(activeTab === 'ativos' ? !exam.isDeleted : !!exam.isDeleted) &&
(exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
data.classes.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase()))
);
const handleStartCreate = () => {
@ -69,17 +71,24 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const handleDeleteExam = (examId: string) => {
showConfirm(
'Excluir Avaliação',
'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.',
'Mover para Lixeira',
'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 });
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 = () => {
if (!duplicatingExam || !targetClassId) return;
@ -515,6 +524,20 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div>
<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">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
@ -554,8 +577,8 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
{filteredExams.map(exam => {
const classObj = data.classes.find(c => c.id === exam.classId);
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 className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
<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 rounded-l-2xl ${exam.isDeleted ? 'bg-slate-400' : 'bg-indigo-500'}`}></div>
<div className="flex justify-between items-start mb-4">
<div className="flex flex-col gap-1">
<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>
</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>
</div>
<div className="space-y-2 mb-6">
@ -612,6 +635,15 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
)}
</div>
<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">
<button
onClick={() => handleToggleRetake(exam.id)}
@ -640,7 +672,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<button
onClick={() => handleDeleteExam(exam.id)}
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} />
</button>
@ -649,8 +681,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
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"
>
Editar {exam.evaluationType === 'activity' ? 'Atividade' : 'Prova'}
Editar <ArrowLeft size={16} className="rotate-180" />
</button>
</>
)}
</div>
</div>
);

View File

@ -259,17 +259,18 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
let totalCount = 0;
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
const periodSums: number[] = [];
const periodAvgs: number[] = [];
Object.values(subjectPeriods).forEach((examValues: any) => {
const sum: number = Object.values(examValues).reduce<number>((a, b: any) => a + (b !== '' ? Number(b) : 0), 0);
if (Object.values(examValues).some(v => v !== '')) {
periodSums.push(sum);
const validValues = Object.values(examValues).filter(v => v !== '');
if (validValues.length > 0) {
const sum: number = validValues.reduce<number>((a, b: any) => a + Number(b), 0);
periodAvgs.push(sum / validValues.length);
}
});
if (periodSums.length > 0) {
const subjectAvg = periodSums.reduce((a, b) => a + b, 0) / periodSums.length;
if (periodAvgs.length > 0) {
const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length;
totalSum += subjectAvg;
totalCount++;
}
@ -292,15 +293,23 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
subjectsWithGrades.forEach(subId => {
const subGrades = studentGradesList.filter(g => g.subjectId === subId);
const periodSums: Record<string, number> = {};
const periodValues: Record<string, number[]> = {};
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;
if (periodsCount > 0) {
const totalSum = Object.values(periodSums).reduce((a, b) => a + b, 0);
subjectAverages.push(totalSum / periodsCount);
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 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">
MÉDIA: {(() => {
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 valid = pSums.filter(s => s > 0);
return valid.length > 0 ? (valid.reduce((a, b) => a + b, 0) / valid.length).toFixed(1) : '0.0';
const pAvgs: number[] = [];
Object.values(subjectGrades).forEach((exVals: any) => {
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>
@ -626,13 +640,16 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
(e.status === 'published' || !!studentSubmissions[String(e.id).trim()])
);
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 (
<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">
<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>
{linkedExams.length > 0 ? (

View File

@ -97,7 +97,9 @@ const App = () => {
saveTimeoutRef.current = setTimeout(async () => {
try {
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');
} else if (result.reason === 'newer_version') {
setSyncStatus('conflict');

View File

@ -202,10 +202,14 @@ app.put('/api/school-data', async (req, res) => {
// Sincronização em tempo real (JSON -> Relacional)
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) {
console.error('Erro ao salvar school_data:', error);
res.status(500).json({ success: false, reason: 'error' });
console.error('Erro ao salvar school-data:', error);
res.status(500).json({ success: false, error: error.message });
}
});
@ -978,13 +982,21 @@ app.post('/api/excluir_cobranca', async (req, res) => {
if (!isSinglePayment) {
const asaasTargetId = formatInstallmentId(id);
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 {
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' }); }
// 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) {
console.error('[Exclusão] Erro:', error);
return res.status(500).json({ error: 'Erro interno.' });

View File

@ -351,6 +351,13 @@ export async function syncJsonToRelationalTables() {
// 1. Sincronizar Cursos
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) {
if (!c.id || !c.name) continue;
await client.query(
@ -369,8 +376,15 @@ export async function syncJsonToRelationalTables() {
// Garantir colunas de refação em provas
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)) {
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) {
if (!sub.id || !sub.name) continue;
await client.query(
@ -384,6 +398,13 @@ export async function syncJsonToRelationalTables() {
// 3. Sincronizar Períodos (Bimestres)
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) {
if (!p.id || !p.name) continue;
await client.query(
@ -397,6 +418,13 @@ export async function syncJsonToRelationalTables() {
// 4. Sincronizar Turmas
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) {
if (!t.id || !t.name) continue;
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)) {
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) {
if (!s.id || !s.name) continue;
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)) {
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) {
if (!e.id || !e.title) continue;
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)) {
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) {
if (!f.id || !f.studentId || !f.classId) continue;
await client.query(

View File

@ -255,7 +255,7 @@ export const dbService = {
// Em vez de: supabase.from('school_data').upsert(...)
// 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 {
const response = await fetch('/api/school-data', {
method: 'PUT',
@ -263,8 +263,9 @@ export const dbService = {
body: JSON.stringify(data),
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 409 && result.reason === 'newer_version') {
console.warn("Servidor tem versão mais nova. Abortando save.");
return { success: false, reason: 'newer_version' };
@ -272,7 +273,10 @@ export const dbService = {
throw new Error('Erro ao salvar');
}
return { success: true };
return {
success: true,
lastUpdated: result.lastUpdated
};
} catch (e) {
console.error("Erro ao salvar na nuvem:", e);
return { success: false, reason: 'error' };

View File

@ -253,6 +253,7 @@ export interface Exam {
allowRetake?: boolean;
evaluationType?: 'exam' | 'activity';
maxScore?: number;
isDeleted?: boolean;
}
export interface SchoolData {

View File

@ -540,7 +540,7 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
if (!student) return res.json({ exams: [], submissions: [] });
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 => ({
...e,
questions: e.questions.map(q => ({

View File

@ -56,10 +56,43 @@ export default function Notas() {
}));
// General average logic
const validGrades = grades.filter(g => g.value > 0);
const totalAvg = displaySubjects.length > 0 && validGrades.length > 0
? validGrades.reduce((s, g) => s + g.value, 0) / validGrades.length
: 0;
const calculateGeneralAverage = () => {
if (displaySubjects.length === 0 || grades.length === 0) return 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 percentage = (value / maxScore) * 10;
@ -134,10 +167,30 @@ export default function Notas() {
const subjectName = typeof subject === 'string' ? subject : subject.name;
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 (
<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>
{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 style={{ padding: '1.5rem' }}>
@ -145,14 +198,14 @@ export default function Notas() {
const periodGrades = subjectGrades.filter(g => String(g.periodName || g.period) === String(period));
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 (
<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' }}>
<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)' }}>
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>