feat: novo layout de boletim, suporte a refazer provas e nomenclatura unificada

This commit is contained in:
Sidney 2026-04-29 09:39:12 -03:00
parent 2f50468cc5
commit bf4ebd8b6b
10 changed files with 177 additions and 76 deletions

View File

@ -32,3 +32,6 @@
10. **Justification Logic**: Attendance justifications MUST include `fromStudentId` in notification metadata and support both `arquivo` and `arquivo_base64` keys for attachment compatibility. Notifications SHOULD include the motive text in the `message` field (JSON format: `{text, motivo}`) to allow previews in the manager bell.
11. **Modal Standards**: System modals should utilize `bg-transparent` (no background darkening or blur) with `shadow-2xl` for depth, ensuring a clean and float-like aesthetic.
12. **Messages Automation**: Preventive reminders and overdue settings MUST be managed within their respective template modals (contextual logic), with independent manual triggers for each phase (Overdue vs. Upcoming).
13. **Grading & Evaluation Standards**: Assessments are categorized as `exam` (violet/violet labels) or `activity` (sky/blue labels). The report card (Boletim) MUST support multiple evaluations per period, showing the individual breakdown (name and value). The module MUST be named **"Atividades e Provas"** for clarity.
14. **Asaas Safety**: All financial generation forms MUST implement a loading state (`isCreating`) to disable submit buttons and prevent duplicate charges.
15. **Retake Policy**: Students ARE allowed to retake activities and exams. The system MUST delete the previous submission and overwrite the grade in `school_data.json` to ensure only the latest attempt is valid.

View File

@ -12,11 +12,12 @@
- [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager).
- [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU.
- [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino.
- [x] **Segurança Financeira:** Implementado estado de carregamento (`isCreating`) no botão de gerar cobranças para impedir disparos duplicados ao Asaas por cliques múltiplos.
- [x] **Boletim & Avaliações:** Refatoração completa do sistema de notas para suportar múltiplas avaliações por período, integrando notas diretas e notas vindas de provas/atividades online.
- [x] **Sincronia Portal/Manager:** Ajustada a submissão de provas no Portal para calcular notas via `maxScore` e injetar automaticamente no boletim do Gerenciador via `examId`.
- [x] **Padronização de Servidor:** Confirmado o uso de `server.selfhosted.js` em ambos os apps como ponto de entrada para garantir 100% de funcionalidades locais.
- [ ] Próximo Passo: Monitorar o desempenho das submissões de provas simultâneas no Portal.
- [x] **Segurança Financeira:** Implementada trava de segurança (`isCreating`) contra cliques múltiplos em formulários financeiros, resolvendo a duplicidade de cobranças no Asaas.
- [x] **Boletim Detalhado (Manager):** Upgrade para layout de lista (Full-Width) com cores distintas: **Violeta (Provas)** e **Azul (Atividades)**.
- [x] **Retake Logic:** Implementada possibilidade de refazer Provas/Atividades no Portal, com substituição automática da nota anterior e limpeza de submissão no banco.
- [x] **Mapeamento de Períodos:** Corrigido o bug que exibia UUIDs (códigos) no boletim do aluno; agora exibe os nomes amigáveis (ex: 1º Bimestre).
- [x] **Nomenclatura Unificada:** Alterado "Avaliações" para **"Atividades e Provas"** em todo o ecossistema (Portal e Manager).
- [ ] Próximo Passo: Analisar a necessidade de pesos diferenciados (médias ponderadas) entre Atividades e Provas no cálculo do boletim.
### 💳 Módulo Financeiro (Portal do Aluno)
- **Funcionalidades Implementadas:**

View File

@ -139,7 +139,7 @@ const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavi
<div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
<div>
<h3 className="font-black text-slate-800 flex items-center gap-2">Avaliações Pendentes
<h3 className="font-black text-slate-800 flex items-center gap-2">Atividades/Provas Pendentes
{unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>}
</h3>
</div>

View File

@ -419,7 +419,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h2 className="text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3">
<BookOpen className="text-indigo-600" size={32} /> Avaliações
<BookOpen className="text-indigo-600" size={32} /> Atividades e Provas
</h2>
<p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p>
</div>

View File

@ -490,12 +490,27 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
<div className="flex items-center gap-2">
{linkedExams.length > 0 && (
{(() => {
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published');
const provasCount = linkedExams.filter(e => (e as any).evaluationType !== 'activity').length;
const atividadesCount = linkedExams.filter(e => (e as any).evaluationType === 'activity').length;
return (
<>
{provasCount > 0 && (
<div className="px-3 py-1 bg-violet-50 border border-violet-200 rounded-lg text-[10px] font-black text-violet-600 flex items-center gap-1">
<FileText size={12} />
{linkedExams.length} {linkedExams.length === 1 ? 'Prova' : 'Provas'}
{provasCount} {provasCount === 1 ? 'Prova' : 'Provas'}
</div>
)}
{atividadesCount > 0 && (
<div className="px-3 py-1 bg-sky-50 border border-sky-200 rounded-lg text-[10px] font-black text-sky-600 flex items-center gap-1">
<FileText size={12} />
{atividadesCount} {atividadesCount === 1 ? 'Atividade' : 'Atividades'}
</div>
)}
</>
);
})()}
<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] || {};
@ -506,7 +521,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="flex flex-col gap-6">
{periods.map(period => {
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
@ -520,28 +535,35 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
</div>
{linkedExams.length > 0 ? (
<div className="space-y-3">
<div className="space-y-4">
{linkedExams.map(exam => {
const isActivity = (exam as any).evaluationType === 'activity';
const maxScore = (exam as any).maxScore ?? 10;
return (
<div key={exam.id} className="space-y-1">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-slate-600 truncate pr-2" title={exam.title}>
<span className={`mr-1 px-1.5 py-0.5 rounded text-[8px] uppercase tracking-wider ${isActivity ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
{isActivity ? 'Ativ' : 'Prova'}
<div key={exam.id} className={`p-4 rounded-xl border flex flex-col md:flex-row md:items-center justify-between gap-4 ${isActivity ? 'bg-sky-50/50 border-sky-100' : 'bg-violet-50/50 border-violet-100'}`}>
<div className="flex-1">
<div className="flex flex-col">
<div className="text-sm font-bold text-slate-800 leading-tight mb-1 flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-black shrink-0 ${isActivity ? 'bg-sky-200 text-sky-800' : 'bg-violet-200 text-violet-800'}`}>
{isActivity ? 'Atividade' : 'Prova'}
</span>
{exam.title}
</span>
<span className="text-[9px] text-slate-400 font-bold whitespace-nowrap shrink-0">Vale {maxScore}</span>
</div>
{exam.description && (
<p className="text-xs text-slate-500 leading-snug pr-2">{exam.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[10px] font-bold text-slate-500 uppercase">Nota (Máx {maxScore})</span>
<input
type="number"
min="0"
max={maxScore}
step="0.1"
placeholder="—"
className="w-full px-2 py-1.5 bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center"
className={`w-24 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-all text-sm font-black text-center ${isActivity ? 'bg-white border-sky-200 focus:ring-sky-500' : 'bg-white border-violet-200 focus:ring-violet-500'}`}
value={studentGrades[subject.id]?.[period.id]?.[exam.id] ?? ''}
onChange={(e) => {
let val = parseFloat(e.target.value);
@ -560,6 +582,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
}}
/>
</div>
</div>
)
})}
</div>

View File

@ -44,7 +44,7 @@ const Sidebar: React.FC<SidebarProps> = ({ currentView, setView, user, logo, onL
{ id: View.Courses, icon: GraduationCap, label: 'Cursos' },
{ id: View.Students, icon: Users, label: 'Alunos' },
{ id: View.Classes, icon: BookOpen, label: 'Turmas' },
{ id: View.Exams, icon: ClipboardList, label: 'Avaliações' },
{ id: View.Exams, icon: ClipboardList, label: 'Atividades e Provas' },
{ id: View.ReportCard, icon: FileText, label: 'Boletim Escolar' },
{ id: View.Finance, icon: CircleDollarSign, label: 'Financeiro' },
{ id: View.Contracts, icon: FileSignature, label: 'Contratos' },

View File

@ -251,15 +251,17 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
const enrichedGrades = grades.map((g) => {
const subject = subjects.find((s) => s.id === g.subjectId);
const exam = g.examId ? (schoolData.exams || []).find(e => e.id === g.examId) : null;
const periodObj = (schoolData.periods || []).find(p => p.id === g.period);
return {
...g,
subjectName: subject?.name || 'Disciplina desconhecida',
examTitle: exam?.title,
evaluationType: exam?.evaluationType || 'exam',
maxScore: exam?.maxScore
maxScore: exam?.maxScore,
periodName: periodObj ? periodObj.name : g.period
};
});
const periods = [...new Set(grades.map((g) => g.period))];
const periods = [...new Set(enrichedGrades.map((g) => g.periodName))];
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
periods.sort();
res.json({ grades: enrichedGrades, periods, allSubjects: courseSubjects });
@ -519,12 +521,17 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
const { examId, answers } = req.body;
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
// Verificar se já submeteu
// Verificar se já submeteu e deletar a submissão anterior para permitir refazer
const { rows: existing } = await pool.query(
'SELECT id FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
'SELECT * FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2 LIMIT 1',
[req.user.studentId, examId]
);
if (existing.length > 0) return res.status(409).json({ error: 'Você já realizou esta prova.' });
if (existing.length > 0) {
await pool.query(
'DELETE FROM provas_submissoes WHERE aluno_id = $1 AND prova_id = $2',
[req.user.studentId, examId]
);
}
const schoolData = await getSchoolData();
const exam = (schoolData.exams || []).find(e => e.id === examId);

View File

@ -12,7 +12,7 @@ const navItems = [
{ path: '/minhas-aulas', label: 'Cronograma', icon: CalendarClock },
{ path: '/financeiro', label: 'Financeiro', icon: CreditCard },
{ path: '/notas', label: 'Notas', icon: BookOpen },
{ path: '/avaliacoes', label: 'Avaliações', icon: ClipboardList },
{ path: '/avaliacoes', label: 'Atividades e Provas', icon: ClipboardList },
{ path: '/frequencia', label: 'Frequência', icon: CalendarCheck },
{ path: '/contratos', label: 'Contratos', icon: FileText },
{ path: '/certificados', label: 'Certificados', icon: Award },

View File

@ -26,6 +26,7 @@ export default function Avaliacoes() {
const [exams, setExams] = useState<Exam[]>([]);
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'exams' | 'activities'>('exams');
// Exam mode state
const [view, setView] = useState<ExamView>('listing');
@ -598,7 +599,7 @@ export default function Avaliacoes() {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<ArrowLeft size={18} /> Voltar às Avaliações
<ArrowLeft size={18} /> Voltar às Atividades e Provas
</button>
</div>
</div>
@ -608,14 +609,62 @@ export default function Avaliacoes() {
// ==========================================
// RENDER: Listing (Default)
// ==========================================
const filteredExams = exams.filter(e => {
const isActivity = (e as any).evaluationType === 'activity';
return activeTab === 'activities' ? isActivity : !isActivity;
});
return (
<div className="page-container">
<div className="animate-fade-in" style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title">Avaliações</h1>
<p className="page-subtitle">Provas e avaliações disponíveis para você</p>
<h1 className="page-title">Atividades e Provas</h1>
<p className="page-subtitle">Provas e atividades disponíveis para você</p>
</div>
{exams.length === 0 ? (
{/* Tabs */}
<div className="animate-fade-in" style={{
display: 'flex', gap: '0.5rem', marginBottom: '2rem',
background: 'var(--color-surface-light)', padding: '0.5rem',
borderRadius: '12px', border: '1px solid var(--glass-border)',
width: 'fit-content'
}}>
<button
onClick={() => setActiveTab('exams')}
style={{
padding: '0.5rem 1.5rem',
borderRadius: '8px',
border: 'none',
fontSize: '0.85rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'all 0.2s',
background: activeTab === 'exams' ? 'var(--color-primary)' : 'transparent',
color: activeTab === 'exams' ? 'white' : 'var(--color-text-secondary)',
boxShadow: activeTab === 'exams' ? '0 2px 8px var(--bg-primary-alpha)' : 'none'
}}
>
Provas ({exams.filter(e => (e as any).evaluationType !== 'activity').length})
</button>
<button
onClick={() => setActiveTab('activities')}
style={{
padding: '0.5rem 1.5rem',
borderRadius: '8px',
border: 'none',
fontSize: '0.85rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'all 0.2s',
background: activeTab === 'activities' ? 'var(--color-info, #0369a1)' : 'transparent',
color: activeTab === 'activities' ? 'white' : 'var(--color-text-secondary)',
boxShadow: activeTab === 'activities' ? '0 2px 8px rgba(3, 105, 161, 0.2)' : 'none'
}}
>
Atividades ({exams.filter(e => (e as any).evaluationType === 'activity').length})
</button>
</div>
{filteredExams.length === 0 ? (
<div className="glass-card animate-fade-in" style={{
padding: '4rem 2rem', textAlign: 'center',
color: 'var(--color-text-secondary)',
@ -630,7 +679,7 @@ export default function Avaliacoes() {
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem',
}} className="animate-fade-in stagger-children">
{exams.map(exam => {
{filteredExams.map(exam => {
const sub = getSubmission(exam.id);
const isDone = !!sub;
@ -699,6 +748,7 @@ export default function Avaliacoes() {
</div>
{isDone ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0.75rem 1rem', borderRadius: 10,
@ -717,6 +767,22 @@ export default function Avaliacoes() {
</p>
</div>
</div>
<button
onClick={() => {
showAppConfirm('Deseja realmente refazer? Sua nota anterior será substituída.', () => startExam(exam));
}}
style={{
width: '100%', padding: '0.65rem',
borderRadius: 10, border: '1px solid var(--color-primary-alpha)',
background: 'transparent', color: 'var(--color-primary)',
fontSize: '0.8rem', fontWeight: 700,
cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
Refazer {(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
</button>
</div>
) : (
<button
onClick={() => startExam(exam)}

View File

@ -8,6 +8,7 @@ interface GradeWithSubject extends Grade {
examTitle?: string;
evaluationType?: string;
maxScore?: number;
periodName?: string;
}
export default function Notas() {
@ -139,7 +140,7 @@ export default function Notas() {
<div style={{ padding: '1.5rem' }}>
{periods.map(period => {
const periodGrades = subjectGrades.filter(g => g.period === period);
const periodGrades = subjectGrades.filter(g => (g.periodName || g.period) === period);
if (periodGrades.length === 0) return null;
const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0);