feat: novo layout de boletim, suporte a refazer provas e nomenclatura unificada
This commit is contained in:
parent
2f50468cc5
commit
bf4ebd8b6b
|
|
@ -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.
|
||||
|
|
|
|||
11
MEMORY.md
11
MEMORY.md
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue