feat: trava de duplicidade financeira, boletim multiavaliações e melhorias no portal
This commit is contained in:
parent
065476df16
commit
2f50468cc5
19
MEMORY.md
19
MEMORY.md
|
|
@ -12,13 +12,11 @@
|
||||||
- [x] Correção das Imagens de Prova: Normalização das URLs nas questões de avaliações (Portal e Manager).
|
- [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] 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] 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] **Mensagens e Automação Financeira:** Implementado sistema seletivo de disparos (Atrasados vs Preventivos) com botões independentes e lógica de servidor desacoplada.
|
- [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] **Configuração Contextual:** Configurações de automação (dias antes/depois, repetições) movidas da sidebar global para dentro dos modais de cada modelo de mensagem.
|
- [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] **Refinamento de UX/UI:** Correção de modais de Frequência para padrão `bg-transparent` e adição de cor `indigo` para identificação de lembretes preventivos.
|
- [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] Registro de Frequência: Implementado ícone de olho para abrir modal com texto completo da justificativa e botão de aprovação rápida.
|
- [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.
|
||||||
- [x] Padronização Visual: Modais atualizados para `bg-transparent` (sem escurecimento/blur) com `shadow-2xl` para efeito de flutuação premium.
|
- [ ] Próximo Passo: Monitorar o desempenho das submissões de provas simultâneas no Portal.
|
||||||
- [x] Backup & Sincronia: Portal agora envia metadados de justificativa filtráveis pelo Gerenciador.
|
|
||||||
- [ ] Próximo Passo: Analisar logs de comportamento dos disparos preventivos em larga escala.
|
|
||||||
|
|
||||||
### 💳 Módulo Financeiro (Portal do Aluno)
|
### 💳 Módulo Financeiro (Portal do Aluno)
|
||||||
- **Funcionalidades Implementadas:**
|
- **Funcionalidades Implementadas:**
|
||||||
|
|
@ -29,6 +27,13 @@
|
||||||
- Visualização de recibos via link externo ou modal de impressão local.
|
- Visualização de recibos via link externo ou modal de impressão local.
|
||||||
- **Onde paramos:** O sistema de filtros e ordenação está funcional, sincronizando com os parâmetros da URL.
|
- **Onde paramos:** O sistema de filtros e ordenação está funcional, sincronizando com os parâmetros da URL.
|
||||||
|
|
||||||
|
### 📝 Módulo de Avaliações (Portal do Aluno)
|
||||||
|
- **Funcionalidades Implementadas:**
|
||||||
|
- Tela de realização de provas e atividades online com cronômetro e suporte a imagens de apoio (MinIO).
|
||||||
|
- **Autocorreção 100% Automática:** O backend do portal (`server.js`) recebe as respostas, compara com o gabarito (`correctOptionIndex`), calcula o percentual de acertos e a nota proporcional ao peso da prova (`finalScore`).
|
||||||
|
- **Lançamento Automático no Boletim:** A nota calculada é salva no PostgreSQL (`provas_submissoes`) e injetada instantaneamente na tabela de notas (`grades`) do `school_data`.
|
||||||
|
- Bloqueio inteligente contra dupla submissão da mesma prova.
|
||||||
|
|
||||||
### ⚙️ Módulo de Configurações e Infra (Manager)
|
### ⚙️ Módulo de Configurações e Infra (Manager)
|
||||||
- **Arquitetura de Armazenamento:** Implementada a transição para **Self-Hosted Storage (MinIO)**.
|
- **Arquitetura de Armazenamento:** Implementada a transição para **Self-Hosted Storage (MinIO)**.
|
||||||
- Extração de Base64 concluída com sucesso via `migrate_images_to_minio.ts`.
|
- Extração de Base64 concluída com sucesso via `migrate_images_to_minio.ts`.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
try {
|
try {
|
||||||
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
|
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
|
||||||
if (match) return `/storage/${match[1]}`;
|
if (match) return `/storage/${match[1]}`;
|
||||||
} catch(e) {}
|
} catch (e) { }
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,8 +38,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
classId: data.classes[0]?.id || '',
|
classId: data.classes[0]?.id || '',
|
||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
questions: []
|
questions: [],
|
||||||
});
|
evaluationType: 'exam',
|
||||||
|
maxScore: 10
|
||||||
|
} as any);
|
||||||
setCurrentView('builder');
|
setCurrentView('builder');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,7 +100,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
if (!editingExam) return;
|
if (!editingExam) return;
|
||||||
const newQuestions = [...editingExam.questions];
|
const newQuestions = [...editingExam.questions];
|
||||||
newQuestions[qIndex].options.splice(oIndex, 1);
|
newQuestions[qIndex].options.splice(oIndex, 1);
|
||||||
|
|
||||||
// Adjust correctOptionIndex if needed
|
// Adjust correctOptionIndex if needed
|
||||||
if (newQuestions[qIndex].correctOptionIndex >= newQuestions[qIndex].options.length) {
|
if (newQuestions[qIndex].correctOptionIndex >= newQuestions[qIndex].options.length) {
|
||||||
newQuestions[qIndex].correctOptionIndex = Math.max(0, newQuestions[qIndex].options.length - 1);
|
newQuestions[qIndex].correctOptionIndex = Math.max(0, newQuestions[qIndex].options.length - 1);
|
||||||
|
|
@ -111,7 +113,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
const handleImageUpload = async (qIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = async (qIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
const url = await uploadExamImage(file);
|
const url = await uploadExamImage(file);
|
||||||
|
|
@ -134,7 +136,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
const handleSave = (status: 'draft' | 'published') => {
|
const handleSave = (status: 'draft' | 'published') => {
|
||||||
if (!editingExam) return;
|
if (!editingExam) return;
|
||||||
|
|
||||||
if (!editingExam.title || !editingExam.classId) {
|
if (!editingExam.title || !editingExam.classId) {
|
||||||
alert('Preencha o título e a turma antes de salvar.');
|
alert('Preencha o título e a turma antes de salvar.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -143,7 +145,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
const finalExam = { ...editingExam, status };
|
const finalExam = { ...editingExam, status };
|
||||||
const currentExams = data.exams || [];
|
const currentExams = data.exams || [];
|
||||||
const existingIndex = currentExams.findIndex(e => e.id === finalExam.id);
|
const existingIndex = currentExams.findIndex(e => e.id === finalExam.id);
|
||||||
|
|
||||||
let newExams;
|
let newExams;
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
newExams = [...currentExams];
|
newExams = [...currentExams];
|
||||||
|
|
@ -151,7 +153,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
} else {
|
} else {
|
||||||
newExams = [...currentExams, finalExam];
|
newExams = [...currentExams, finalExam];
|
||||||
}
|
}
|
||||||
|
|
||||||
updateData({ exams: newExams });
|
updateData({ exams: newExams });
|
||||||
setCurrentView('list');
|
setCurrentView('list');
|
||||||
setEditingExam(null);
|
setEditingExam(null);
|
||||||
|
|
@ -161,7 +163,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl mx-auto animate-in fade-in duration-500 pb-32">
|
<div className="p-8 max-w-4xl mx-auto animate-in fade-in duration-500 pb-32">
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="flex items-center gap-4 mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentView('list')}
|
onClick={() => setCurrentView('list')}
|
||||||
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
|
|
@ -183,19 +185,41 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Título da Avaliação</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Título da Avaliação</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editingExam.title}
|
value={editingExam.title}
|
||||||
onChange={e => setEditingExam({...editingExam, title: e.target.value})}
|
onChange={e => setEditingExam({ ...editingExam, title: e.target.value })}
|
||||||
placeholder="Ex: Prova Bimestral de Matemática"
|
placeholder="Ex: Prova Bimestral de Matemática"
|
||||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-700 mb-2">Tipo de Avaliação</label>
|
||||||
|
<select
|
||||||
|
value={(editingExam as any).evaluationType || 'exam'}
|
||||||
|
onChange={e => setEditingExam({ ...editingExam, evaluationType: e.target.value } as any)}
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
|
>
|
||||||
|
<option value="exam">Prova</option>
|
||||||
|
<option value="activity">Atividade</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-700 mb-2">Valor (Pontuação Máxima)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(editingExam as any).maxScore ?? 10}
|
||||||
|
onChange={e => setEditingExam({ ...editingExam, maxScore: parseFloat(e.target.value) || 0 } as any)}
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label>
|
||||||
<select
|
<select
|
||||||
value={editingExam.classId}
|
value={editingExam.classId}
|
||||||
onChange={e => setEditingExam({...editingExam, classId: e.target.value})}
|
onChange={e => setEditingExam({ ...editingExam, classId: e.target.value })}
|
||||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
>
|
>
|
||||||
<option value="" disabled>Selecione uma turma</option>
|
<option value="" disabled>Selecione uma turma</option>
|
||||||
|
|
@ -206,19 +230,19 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Duração (Minutos)</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Duração (Minutos)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editingExam.durationMinutes}
|
value={editingExam.durationMinutes}
|
||||||
onChange={e => setEditingExam({...editingExam, durationMinutes: parseInt(e.target.value) || 0})}
|
onChange={e => setEditingExam({ ...editingExam, durationMinutes: parseInt(e.target.value) || 0 })}
|
||||||
min="0"
|
min="0"
|
||||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Disciplina (Boletim)</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Disciplina (Boletim)</label>
|
||||||
<select
|
<select
|
||||||
value={editingExam.subjectId || ''}
|
value={editingExam.subjectId || ''}
|
||||||
onChange={e => setEditingExam({...editingExam, subjectId: e.target.value || undefined})}
|
onChange={e => setEditingExam({ ...editingExam, subjectId: e.target.value || undefined })}
|
||||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
>
|
>
|
||||||
<option value="">Nenhuma (não vincular)</option>
|
<option value="">Nenhuma (não vincular)</option>
|
||||||
|
|
@ -230,9 +254,9 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Período (Boletim)</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Período (Boletim)</label>
|
||||||
<select
|
<select
|
||||||
value={editingExam.periodId || ''}
|
value={editingExam.periodId || ''}
|
||||||
onChange={e => setEditingExam({...editingExam, periodId: e.target.value || undefined})}
|
onChange={e => setEditingExam({ ...editingExam, periodId: e.target.value || undefined })}
|
||||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
|
||||||
>
|
>
|
||||||
<option value="">Nenhum (não vincular)</option>
|
<option value="">Nenhum (não vincular)</option>
|
||||||
|
|
@ -250,13 +274,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
{editingExam.questions.map((question, qIndex) => (
|
{editingExam.questions.map((question, qIndex) => (
|
||||||
<div key={question.id} className="bg-white rounded-2xl p-8 shadow-md border border-slate-200 relative group animate-slide-up">
|
<div key={question.id} className="bg-white rounded-2xl p-8 shadow-md border border-slate-200 relative group animate-slide-up">
|
||||||
<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 bg-indigo-500 rounded-l-2xl"></div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h4 className="text-lg font-black text-slate-800 flex items-center gap-2">
|
<h4 className="text-lg font-black text-slate-800 flex items-center gap-2">
|
||||||
<span className="w-8 h-8 rounded-lg bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm">{qIndex + 1}</span>
|
<span className="w-8 h-8 rounded-lg bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm">{qIndex + 1}</span>
|
||||||
Questão
|
Questão
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveQuestion(qIndex)}
|
onClick={() => handleRemoveQuestion(qIndex)}
|
||||||
className="text-slate-400 hover:text-red-500 transition-colors p-2"
|
className="text-slate-400 hover:text-red-500 transition-colors p-2"
|
||||||
title="Remover Questão"
|
title="Remover Questão"
|
||||||
|
|
@ -269,7 +293,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
{/* Enunciado */}
|
{/* Enunciado */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">Enunciado</label>
|
<label className="block text-sm font-bold text-slate-700 mb-2">Enunciado</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={question.text}
|
value={question.text}
|
||||||
onChange={e => handleQuestionChange(qIndex, 'text', e.target.value)}
|
onChange={e => handleQuestionChange(qIndex, 'text', e.target.value)}
|
||||||
placeholder="Digite o enunciado da questão..."
|
placeholder="Digite o enunciado da questão..."
|
||||||
|
|
@ -283,7 +307,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
{question.imageUrl ? (
|
{question.imageUrl ? (
|
||||||
<div className="relative inline-block mt-2 group/img">
|
<div className="relative inline-block mt-2 group/img">
|
||||||
<img src={normalizePhotoUrl(question.imageUrl)} alt="Apoio" className="max-w-full md:max-w-md h-auto rounded-xl border border-slate-200 shadow-sm" />
|
<img src={normalizePhotoUrl(question.imageUrl)} alt="Apoio" className="max-w-full md:max-w-md h-auto rounded-xl border border-slate-200 shadow-sm" />
|
||||||
<button
|
<button
|
||||||
onClick={() => handleQuestionChange(qIndex, 'imageUrl', undefined)}
|
onClick={() => handleQuestionChange(qIndex, 'imageUrl', undefined)}
|
||||||
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-lg opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600 shadow-lg"
|
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-lg opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600 shadow-lg"
|
||||||
>
|
>
|
||||||
|
|
@ -292,10 +316,10 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
|
<label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => handleImageUpload(qIndex, e)}
|
onChange={(e) => handleImageUpload(qIndex, e)}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
|
|
@ -314,13 +338,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
{/* Alternativas */}
|
{/* Alternativas */}
|
||||||
<div className="pt-4 border-t border-slate-100">
|
<div className="pt-4 border-t border-slate-100">
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-4">Alternativas</label>
|
<label className="block text-sm font-bold text-slate-700 mb-4">Alternativas</label>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{question.options.map((option, oIndex) => (
|
{question.options.map((option, oIndex) => (
|
||||||
<div key={oIndex} className={`flex items-center gap-3 p-2 rounded-xl transition-colors ${question.correctOptionIndex === oIndex ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50 border border-transparent'}`}>
|
<div key={oIndex} className={`flex items-center gap-3 p-2 rounded-xl transition-colors ${question.correctOptionIndex === oIndex ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50 border border-transparent'}`}>
|
||||||
<div className="flex items-center justify-center w-10">
|
<div className="flex items-center justify-center w-10">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name={`correct-${question.id}`}
|
name={`correct-${question.id}`}
|
||||||
checked={question.correctOptionIndex === oIndex}
|
checked={question.correctOptionIndex === oIndex}
|
||||||
onChange={() => handleQuestionChange(qIndex, 'correctOptionIndex', oIndex)}
|
onChange={() => handleQuestionChange(qIndex, 'correctOptionIndex', oIndex)}
|
||||||
|
|
@ -328,14 +352,14 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
title="Marcar como correta"
|
title="Marcar como correta"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={option}
|
value={option}
|
||||||
onChange={e => handleOptionChange(qIndex, oIndex, e.target.value)}
|
onChange={e => handleOptionChange(qIndex, oIndex, e.target.value)}
|
||||||
placeholder={`Alternativa ${String.fromCharCode(65 + oIndex)}`}
|
placeholder={`Alternativa ${String.fromCharCode(65 + oIndex)}`}
|
||||||
className="flex-1 bg-transparent border-none focus:ring-0 p-2 font-medium text-slate-800 placeholder:text-slate-400"
|
className="flex-1 bg-transparent border-none focus:ring-0 p-2 font-medium text-slate-800 placeholder:text-slate-400"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveOption(qIndex, oIndex)}
|
onClick={() => handleRemoveOption(qIndex, oIndex)}
|
||||||
className="p-2 text-slate-400 hover:text-red-500 transition-colors"
|
className="p-2 text-slate-400 hover:text-red-500 transition-colors"
|
||||||
disabled={question.options.length <= 2}
|
disabled={question.options.length <= 2}
|
||||||
|
|
@ -347,7 +371,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddOption(qIndex)}
|
onClick={() => handleAddOption(qIndex)}
|
||||||
className="mt-4 flex items-center gap-2 text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
|
className="mt-4 flex items-center gap-2 text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
|
||||||
>
|
>
|
||||||
|
|
@ -359,7 +383,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Botão Adicionar Questão */}
|
{/* Botão Adicionar Questão */}
|
||||||
<button
|
<button
|
||||||
onClick={handleAddQuestion}
|
onClick={handleAddQuestion}
|
||||||
className="w-full flex flex-col items-center justify-center gap-3 p-8 border-2 border-dashed border-indigo-200 rounded-2xl text-indigo-600 hover:bg-indigo-50 hover:border-indigo-400 transition-all font-bold group"
|
className="w-full flex flex-col items-center justify-center gap-3 p-8 border-2 border-dashed border-indigo-200 rounded-2xl text-indigo-600 hover:bg-indigo-50 hover:border-indigo-400 transition-all font-bold group"
|
||||||
>
|
>
|
||||||
|
|
@ -372,13 +396,13 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
{/* Sticky Actions Bar */}
|
{/* Sticky Actions Bar */}
|
||||||
<div className="fixed bottom-0 left-0 md:left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-end gap-4 shadow-[0_-10px_40px_-15px_rgba(0,0,0,0.1)] z-40">
|
<div className="fixed bottom-0 left-0 md:left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-end gap-4 shadow-[0_-10px_40px_-15px_rgba(0,0,0,0.1)] z-40">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSave('draft')}
|
onClick={() => handleSave('draft')}
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition-colors"
|
className="flex items-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition-colors"
|
||||||
>
|
>
|
||||||
<Save size={20} /> Salvar como Rascunho
|
<Save size={20} /> Salvar como Rascunho
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSave('published')}
|
onClick={() => handleSave('published')}
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all"
|
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all"
|
||||||
>
|
>
|
||||||
|
|
@ -399,19 +423,19 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p>
|
<p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<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
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar avaliação..."
|
placeholder="Buscar avaliação..."
|
||||||
className="pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm w-full md:w-64 transition-all"
|
className="pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm w-full md:w-64 transition-all"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartCreate}
|
onClick={handleStartCreate}
|
||||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-200"
|
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-200"
|
||||||
>
|
>
|
||||||
|
|
@ -428,7 +452,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-slate-700 mb-2">Nenhuma avaliação encontrada</h3>
|
<h3 className="text-xl font-bold text-slate-700 mb-2">Nenhuma avaliação encontrada</h3>
|
||||||
<p className="text-slate-500 mb-6 max-w-md">Você ainda não criou nenhuma prova ou os filtros não retornaram resultados.</p>
|
<p className="text-slate-500 mb-6 max-w-md">Você ainda não criou nenhuma prova ou os filtros não retornaram resultados.</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartCreate}
|
onClick={handleStartCreate}
|
||||||
className="px-6 py-3 bg-indigo-50 text-indigo-700 rounded-xl font-bold hover:bg-indigo-100 transition-colors"
|
className="px-6 py-3 bg-indigo-50 text-indigo-700 rounded-xl font-bold hover:bg-indigo-100 transition-colors"
|
||||||
>
|
>
|
||||||
|
|
@ -443,41 +467,51 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
<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="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 className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
|
<div className="flex flex-col gap-1">
|
||||||
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 ${
|
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
|
||||||
exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'
|
<div className="flex items-center gap-2 mt-1">
|
||||||
}`}>
|
<span className={`px-2 py-0.5 text-[10px] font-black uppercase tracking-wider rounded-md ${(exam as any).evaluationType === 'activity' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'
|
||||||
|
}`}>
|
||||||
|
{(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-500">
|
||||||
|
Vale: {(exam as any).maxScore ?? 10} pts
|
||||||
|
</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'
|
||||||
|
}`}>
|
||||||
{exam.status === 'published' ? 'Publicada' : 'Rascunho'}
|
{exam.status === 'published' ? 'Publicada' : 'Rascunho'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2 mb-6">
|
||||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||||
<span className="font-bold text-slate-700">Turma:</span>
|
<span className="font-bold text-slate-700">Turma:</span>
|
||||||
{classObj?.name || 'Turma não encontrada'}
|
{classObj?.name || 'Turma não encontrada'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||||
<span className="font-bold text-slate-700">Questões:</span>
|
<span className="font-bold text-slate-700">Questões:</span>
|
||||||
{exam.questions?.length || 0}
|
{exam.questions?.length || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||||
<span className="font-bold text-slate-700">Duração:</span>
|
<span className="font-bold text-slate-700">Duração:</span>
|
||||||
{exam.durationMinutes} min
|
{exam.durationMinutes} min
|
||||||
</p>
|
</p>
|
||||||
{exam.subjectId && (
|
{exam.subjectId && (
|
||||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||||
<span className="font-bold text-slate-700">Disciplina:</span>
|
<span className="font-bold text-slate-700">Disciplina:</span>
|
||||||
{(data.subjects || []).find(s => s.id === exam.subjectId)?.name || '—'}
|
{(data.subjects || []).find(s => s.id === exam.subjectId)?.name || '—'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{exam.periodId && (
|
{exam.periodId && (
|
||||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||||
<span className="font-bold text-slate-700">Período:</span>
|
<span className="font-bold text-slate-700">Período:</span>
|
||||||
{(data.periods || []).find(p => p.id === exam.periodId)?.name || '—'}
|
{(data.periods || []).find(p => p.id === exam.periodId)?.name || '—'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-100 pt-4 flex justify-end">
|
<div className="border-t border-slate-100 pt-4 flex justify-end">
|
||||||
<button
|
<button
|
||||||
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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
||||||
const [isFetchingCarne, setIsFetchingCarne] = useState(false);
|
const [isFetchingCarne, setIsFetchingCarne] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
syncAsaasPayments();
|
syncAsaasPayments();
|
||||||
|
|
@ -494,6 +495,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
const handleCreatePayment = async (e: React.FormEvent) => {
|
const handleCreatePayment = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isCreating) return;
|
||||||
|
|
||||||
if (!formData.studentId || formData.amount <= 0) {
|
if (!formData.studentId || formData.amount <= 0) {
|
||||||
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e informe um valor válido.', 'warning');
|
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e informe um valor válido.', 'warning');
|
||||||
|
|
@ -506,7 +508,9 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPayments: Payment[] = [];
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const newPayments: Payment[] = [];
|
||||||
|
|
||||||
let baseDateStr = formData.dueDate;
|
let baseDateStr = formData.dueDate;
|
||||||
if (dueDateDisplay.length === 10) {
|
if (dueDateDisplay.length === 10) {
|
||||||
|
|
@ -573,16 +577,19 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
// Validação de campos obrigatórios para o Asaas Oficial
|
// Validação de campos obrigatórios para o Asaas Oficial
|
||||||
if (!finalCpf || finalCpf.length < 11) {
|
if (!finalCpf || finalCpf.length < 11) {
|
||||||
showAlert('Erro de Cadastro', `O ${isMinor ? 'responsável' : 'aluno'} precisa ter um CPF válido cadastrado para gerar cobrança no Asaas Oficial.`, 'error');
|
showAlert('Erro de Cadastro', `O ${isMinor ? 'responsável' : 'aluno'} precisa ter um CPF válido cadastrado para gerar cobrança no Asaas Oficial.`, 'error');
|
||||||
|
setIsCreating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!student.addressZip || student.addressZip.length < 8) {
|
if (!student.addressZip || student.addressZip.length < 8) {
|
||||||
showAlert('Erro de Cadastro', 'O CEP do aluno é obrigatório e deve ser válido para o Asaas Oficial.', 'error');
|
showAlert('Erro de Cadastro', 'O CEP do aluno é obrigatório e deve ser válido para o Asaas Oficial.', 'error');
|
||||||
|
setIsCreating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!student.addressStreet || !student.addressNumber) {
|
if (!student.addressStreet || !student.addressNumber) {
|
||||||
showAlert('Erro de Cadastro', 'Endereço e Número são obrigatórios no cadastro do aluno para gerar cobrança.', 'error');
|
showAlert('Erro de Cadastro', 'Endereço e Número são obrigatórios no cadastro do aluno para gerar cobrança.', 'error');
|
||||||
|
setIsCreating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -667,6 +674,9 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
});
|
});
|
||||||
showAlert('Sucesso', 'Nova cobrança gerada com sucesso.', 'success');
|
showAlert('Sucesso', 'Nova cobrança gerada com sucesso.', 'success');
|
||||||
closeModal();
|
closeModal();
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
|
|
@ -1317,7 +1327,15 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 flex gap-4">
|
<div className="pt-4 flex gap-4">
|
||||||
<button type="button" onClick={closeModal} className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold text-xs">Cancelar</button>
|
<button type="button" onClick={closeModal} className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold text-xs">Cancelar</button>
|
||||||
<button type="submit" className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold text-xs">Gerar Lançamento</button>
|
<button type="submit" disabled={isCreating} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold text-xs disabled:opacity-50 disabled:cursor-wait">
|
||||||
|
{isCreating ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<RefreshCw size={14} className="animate-spin" /> Gerando...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Gerar Lançamento'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ import React, { useState } from 'react';
|
||||||
import { SchoolData, Class, Student, Subject, Grade, Period } from '../types';
|
import { SchoolData, Class, Student, Subject, Grade, Period } from '../types';
|
||||||
import { dbService } from '../services/dbService';
|
import { dbService } from '../services/dbService';
|
||||||
import { useDialog } from '../DialogContext';
|
import { useDialog } from '../DialogContext';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Save,
|
Save,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
Search,
|
Search,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
|
@ -33,7 +33,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showConfigManager, setShowConfigManager] = useState(false);
|
const [showConfigManager, setShowConfigManager] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
|
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
|
||||||
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, number>>>({}); // subjectId -> periodId -> value
|
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
|
||||||
|
|
||||||
const subjects = data.subjects || [];
|
const subjects = data.subjects || [];
|
||||||
const periods = data.periods || [];
|
const periods = data.periods || [];
|
||||||
|
|
@ -44,12 +44,12 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
if (!url || typeof url !== 'string') return '';
|
if (!url || typeof url !== 'string') return '';
|
||||||
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
|
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
|
||||||
if (url.startsWith('/storage/')) return url;
|
if (url.startsWith('/storage/')) return url;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
|
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
|
||||||
if (match) return `/storage/${match[1]}`;
|
if (match) return `/storage/${match[1]}`;
|
||||||
} catch(e) {}
|
} catch (e) { }
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
const handleDeleteSubject = (id: string) => {
|
const handleDeleteSubject = (id: string) => {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
'Excluir Disciplina',
|
'Excluir Disciplina',
|
||||||
'⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.',
|
'⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.',
|
||||||
() => {
|
() => {
|
||||||
const updatedSubjects = subjects.filter(s => s.id !== id);
|
const updatedSubjects = subjects.filter(s => s.id !== id);
|
||||||
|
|
@ -98,7 +98,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
const handleDeletePeriod = (id: string) => {
|
const handleDeletePeriod = (id: string) => {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
'Excluir Período',
|
'Excluir Período',
|
||||||
'⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.',
|
'⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.',
|
||||||
() => {
|
() => {
|
||||||
const updatedPeriods = periods.filter(p => p.id !== id);
|
const updatedPeriods = periods.filter(p => p.id !== id);
|
||||||
|
|
@ -111,16 +111,27 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
|
|
||||||
const handleOpenStudentGrades = (student: Student) => {
|
const handleOpenStudentGrades = (student: Student) => {
|
||||||
setSelectedStudent(student);
|
setSelectedStudent(student);
|
||||||
const initialGrades: Record<string, Record<string, number>> = {};
|
const initialGrades: Record<string, Record<string, any>> = {};
|
||||||
|
|
||||||
subjects.forEach(subject => {
|
subjects.forEach(subject => {
|
||||||
initialGrades[subject.id] = {};
|
initialGrades[subject.id] = {};
|
||||||
periods.forEach(period => {
|
periods.forEach(period => {
|
||||||
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id);
|
const periodGrades: any = {};
|
||||||
initialGrades[subject.id][period.id] = existingGrade ? existingGrade.value : 0;
|
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
|
||||||
|
|
||||||
|
if (linkedExams.length > 0) {
|
||||||
|
linkedExams.forEach(exam => {
|
||||||
|
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id && (g as any).examId === exam.id);
|
||||||
|
periodGrades[exam.id] = existingGrade ? existingGrade.value : '';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existingGrade = grades.find(g => g.studentId === student.id && g.subjectId === subject.id && g.period === period.id && !(g as any).examId);
|
||||||
|
periodGrades['direct'] = existingGrade ? existingGrade.value : '';
|
||||||
|
}
|
||||||
|
initialGrades[subject.id][period.id] = periodGrades;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setStudentGrades(initialGrades);
|
setStudentGrades(initialGrades);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -130,16 +141,20 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
const newGradesList: Grade[] = [...grades.filter(g => g.studentId !== selectedStudent.id)];
|
const newGradesList: Grade[] = [...grades.filter(g => g.studentId !== selectedStudent.id)];
|
||||||
|
|
||||||
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
|
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
|
||||||
Object.entries(periodGrades).forEach(([periodId, value]) => {
|
Object.entries(periodGrades).forEach(([periodId, examValues]) => {
|
||||||
if (value > 0) {
|
Object.entries(examValues).forEach(([examId, value]) => {
|
||||||
newGradesList.push({
|
const numValue = Number(value);
|
||||||
id: crypto.randomUUID(),
|
if (numValue > 0 || (value !== '' && numValue === 0)) {
|
||||||
studentId: selectedStudent.id,
|
newGradesList.push({
|
||||||
subjectId,
|
id: crypto.randomUUID(),
|
||||||
period: periodId,
|
studentId: selectedStudent.id,
|
||||||
value
|
subjectId,
|
||||||
});
|
period: periodId,
|
||||||
}
|
value: numValue,
|
||||||
|
...(examId !== 'direct' ? { examId } : {})
|
||||||
|
} as Grade);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -153,11 +168,18 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
let totalSum = 0;
|
let totalSum = 0;
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
|
|
||||||
Object.values(studentGrades).forEach(subjectPeriods => {
|
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
|
||||||
const periodValues = Object.values(subjectPeriods).filter((v): v is number => typeof v === 'number' && v > 0);
|
const periodSums: number[] = [];
|
||||||
if (periodValues.length > 0) {
|
|
||||||
const subjectSum = periodValues.reduce((a, b) => a + b, 0);
|
Object.values(subjectPeriods).forEach((examValues: any) => {
|
||||||
const subjectAvg = subjectSum / periodValues.length;
|
const sum = Object.values(examValues).reduce((a: number, b: any) => a + (b !== '' ? Number(b) : 0), 0);
|
||||||
|
if (Object.values(examValues).some(v => v !== '')) {
|
||||||
|
periodSums.push(sum);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (periodSums.length > 0) {
|
||||||
|
const subjectAvg = periodSums.reduce((a, b) => a + b, 0) / periodSums.length;
|
||||||
totalSum += subjectAvg;
|
totalSum += subjectAvg;
|
||||||
totalCount++;
|
totalCount++;
|
||||||
}
|
}
|
||||||
|
|
@ -175,8 +197,17 @@ 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 sum = subGrades.reduce((a, b) => a + b.value, 0);
|
|
||||||
subjectAverages.push(sum / subGrades.length);
|
const periodSums: Record<string, number> = {};
|
||||||
|
subGrades.forEach(g => {
|
||||||
|
periodSums[g.period] = (periodSums[g.period] || 0) + 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subjectAverages.length === 0) return '0.00';
|
if (subjectAverages.length === 0) return '0.00';
|
||||||
|
|
@ -184,7 +215,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
return (totalSum / subjectAverages.length).toFixed(2);
|
return (totalSum / subjectAverages.length).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredClasses = data.classes.filter(c =>
|
const filteredClasses = data.classes.filter(c =>
|
||||||
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
|
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -195,7 +226,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2>
|
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2>
|
||||||
<p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p>
|
<p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfigManager(!showConfigManager)}
|
onClick={() => setShowConfigManager(!showConfigManager)}
|
||||||
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
|
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
|
@ -213,13 +244,13 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
<h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3>
|
<h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfigTab('subjects')}
|
onClick={() => setConfigTab('subjects')}
|
||||||
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
>
|
>
|
||||||
DISCIPLINAS
|
DISCIPLINAS
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfigTab('periods')}
|
onClick={() => setConfigTab('periods')}
|
||||||
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
>
|
>
|
||||||
|
|
@ -231,14 +262,14 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
{configTab === 'subjects' ? (
|
{configTab === 'subjects' ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nome da disciplina (ex: Matemática, Inglês...)"
|
placeholder="Nome da disciplina (ex: Matemática, Inglês...)"
|
||||||
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
|
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
|
||||||
value={newSubjectName}
|
value={newSubjectName}
|
||||||
onChange={(e) => setNewSubjectName(e.target.value)}
|
onChange={(e) => setNewSubjectName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddSubject}
|
onClick={handleAddSubject}
|
||||||
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
|
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
|
@ -250,7 +281,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
{subjects.map(subject => (
|
{subjects.map(subject => (
|
||||||
<div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
|
<div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
|
||||||
<span className="font-bold text-slate-700">{subject.name}</span>
|
<span className="font-bold text-slate-700">{subject.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteSubject(subject.id)}
|
onClick={() => handleDeleteSubject(subject.id)}
|
||||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
|
|
@ -266,14 +297,14 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)"
|
placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)"
|
||||||
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
|
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
|
||||||
value={newPeriodName}
|
value={newPeriodName}
|
||||||
onChange={(e) => setNewPeriodName(e.target.value)}
|
onChange={(e) => setNewPeriodName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddPeriod}
|
onClick={handleAddPeriod}
|
||||||
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
|
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
|
@ -285,7 +316,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
{periods.map(period => (
|
{periods.map(period => (
|
||||||
<div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
|
<div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
|
||||||
<span className="font-bold text-slate-700">{period.name}</span>
|
<span className="font-bold text-slate-700">{period.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeletePeriod(period.id)}
|
onClick={() => handleDeletePeriod(period.id)}
|
||||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
|
|
@ -306,9 +337,9 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
<>
|
<>
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar turmas..."
|
placeholder="Buscar turmas..."
|
||||||
className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm"
|
className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
|
@ -320,8 +351,8 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
const course = data.courses.find(c => c.id === cls.courseId);
|
const course = data.courses.find(c => c.id === cls.courseId);
|
||||||
const studentCount = data.students.filter(s => s.classId === cls.id).length;
|
const studentCount = data.students.filter(s => s.classId === cls.id).length;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cls.id}
|
key={cls.id}
|
||||||
onClick={() => setSelectedClass(cls)}
|
onClick={() => setSelectedClass(cls)}
|
||||||
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden"
|
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -348,7 +379,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6 animate-in slide-in-from-left-4">
|
<div className="space-y-6 animate-in slide-in-from-left-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedClass(null)}
|
onClick={() => setSelectedClass(null)}
|
||||||
className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors"
|
className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors"
|
||||||
>
|
>
|
||||||
|
|
@ -371,36 +402,36 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
.filter(s => s.classId === selectedClass.id)
|
.filter(s => s.classId === selectedClass.id)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.map(student => (
|
.map(student => (
|
||||||
<div
|
<div
|
||||||
key={student.id}
|
key={student.id}
|
||||||
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
|
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
|
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
|
||||||
{student.photo ? (
|
{student.photo ? (
|
||||||
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
|
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<User size={20} />
|
<User size={20} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
|
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
|
||||||
</div>
|
<div className="hidden sm:flex flex-col items-end">
|
||||||
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
|
||||||
<div className="hidden sm:flex flex-col items-end">
|
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
|
{getStudentGeneralAverage(student.id)}
|
||||||
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
|
</span>
|
||||||
{getStudentGeneralAverage(student.id)}
|
</div>
|
||||||
</span>
|
<button
|
||||||
|
onClick={() => handleOpenStudentGrades(student)}
|
||||||
|
className="px-3 py-1.5 bg-white text-indigo-600 border border-indigo-100 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-xs flex items-center gap-1.5 shadow-sm"
|
||||||
|
>
|
||||||
|
<FileText size={14} /> Notas
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleOpenStudentGrades(student)}
|
|
||||||
className="px-3 py-1.5 bg-white text-indigo-600 border border-indigo-100 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-xs flex items-center gap-1.5 shadow-sm"
|
|
||||||
>
|
|
||||||
<FileText size={14} /> Notas
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -422,7 +453,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar • {selectedClass?.name}</p>
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar • {selectedClass?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedStudent(null)}
|
onClick={() => setSelectedStudent(null)}
|
||||||
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"
|
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"
|
||||||
>
|
>
|
||||||
|
|
@ -435,10 +466,10 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
<div className="text-center py-12 space-y-4">
|
<div className="text-center py-12 space-y-4">
|
||||||
<AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" />
|
<AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" />
|
||||||
<p className="text-slate-500 font-medium">
|
<p className="text-slate-500 font-medium">
|
||||||
{subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'}
|
{subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'}
|
||||||
Por favor, complete as configurações primeiro.
|
Por favor, complete as configurações primeiro.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }}
|
onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }}
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm"
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm"
|
||||||
>
|
>
|
||||||
|
|
@ -450,66 +481,121 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
{subjects.map(subject => {
|
{subjects.map(subject => {
|
||||||
// Encontrar provas vinculadas a esta disciplina
|
// Encontrar provas vinculadas a esta disciplina
|
||||||
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published');
|
const linkedExams = (data.exams || []).filter(e => e.subjectId === subject.id && e.status === 'published');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
|
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-indigo-600">
|
<div className="flex items-center gap-2 text-indigo-600">
|
||||||
<BookOpen size={18} />
|
<BookOpen size={18} />
|
||||||
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
|
<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 && (
|
|
||||||
<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'}
|
|
||||||
</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] || {};
|
|
||||||
const vals = Object.values(subjectGrades).filter((v): v is number => typeof v === 'number' && v > 0);
|
|
||||||
return vals.length > 0 ? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1) : '0.0';
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
{linkedExams.length > 0 && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<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">
|
||||||
{periods.map(period => {
|
<FileText size={12} />
|
||||||
// Verificar se há uma prova vinculada a esta disciplina+período
|
{linkedExams.length} {linkedExams.length === 1 ? 'Prova' : 'Provas'}
|
||||||
const linkedExam = (data.exams || []).find(e => e.subjectId === subject.id && e.periodId === period.id && e.status === 'published');
|
</div>
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={period.id} className="space-y-1.5">
|
|
||||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">{period.name}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="10"
|
|
||||||
step="0.1"
|
|
||||||
className={`w-full px-3 py-2 bg-white border rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center ${linkedExam ? 'border-violet-300 ring-1 ring-violet-100' : 'border-slate-200'}`}
|
|
||||||
value={studentGrades[subject.id]?.[period.id] || 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = parseFloat(e.target.value) || 0;
|
|
||||||
setStudentGrades(prev => ({
|
|
||||||
...prev,
|
|
||||||
[subject.id]: {
|
|
||||||
...prev[subject.id],
|
|
||||||
[period.id]: val
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{linkedExam && (
|
|
||||||
<p className="text-[9px] font-bold text-violet-500 truncate ml-1" title={linkedExam.title}>
|
|
||||||
📝 {linkedExam.title}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
<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 = Object.values(subjectGrades).map((exVals: any) => Object.values(exVals).reduce((a: number, 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';
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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] || {};
|
||||||
|
const periodSum = Object.values(periodGrades).reduce((a: number, b: any) => a + (b !== '' ? Number(b) : 0), 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkedExams.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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'}
|
||||||
|
</span>
|
||||||
|
{exam.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-bold whitespace-nowrap shrink-0">Vale {maxScore}</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
value={studentGrades[subject.id]?.[period.id]?.[exam.id] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
let val = parseFloat(e.target.value);
|
||||||
|
if (val > maxScore) val = maxScore;
|
||||||
|
if (val < 0) val = 0;
|
||||||
|
setStudentGrades(prev => ({
|
||||||
|
...prev,
|
||||||
|
[subject.id]: {
|
||||||
|
...prev[subject.id],
|
||||||
|
[period.id]: {
|
||||||
|
...prev[subject.id]?.[period.id],
|
||||||
|
[exam.id]: isNaN(val) ? '' : val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 mt-2">
|
||||||
|
<p className="text-[9px] text-slate-400 font-medium mb-1">Nota Direta (Sem avaliação vinculada)</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="—"
|
||||||
|
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center"
|
||||||
|
value={studentGrades[subject.id]?.[period.id]?.direct ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
let val = parseFloat(e.target.value);
|
||||||
|
if (val > 10) val = 10;
|
||||||
|
if (val < 0) val = 0;
|
||||||
|
setStudentGrades(prev => ({
|
||||||
|
...prev,
|
||||||
|
[subject.id]: {
|
||||||
|
...prev[subject.id],
|
||||||
|
[period.id]: {
|
||||||
|
direct: isNaN(val) ? '' : val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -533,13 +619,13 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
|
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedStudent(null)}
|
onClick={() => setSelectedStudent(null)}
|
||||||
className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all"
|
className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all"
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveGrades}
|
onClick={handleSaveGrades}
|
||||||
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2"
|
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ O EduManager armazena TODOS os dados da escola em uma **única tabela** chamada
|
||||||
attendance: Attendance[], // Registros de presença
|
attendance: Attendance[], // Registros de presença
|
||||||
subjects: Subject[], // Disciplinas
|
subjects: Subject[], // Disciplinas
|
||||||
grades: Grade[], // Notas dos alunos
|
grades: Grade[], // Notas dos alunos
|
||||||
|
exams: Exam[], // Avaliações (Provas e Atividades)
|
||||||
profile: SchoolProfile, // Dados da escola (nome, logo, etc.)
|
profile: SchoolProfile, // Dados da escola (nome, logo, etc.)
|
||||||
logo?: string, // Logo da escola em base64
|
logo?: string, // Logo da escola em base64
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +106,19 @@ interface Grade {
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
value: number;
|
value: number;
|
||||||
period: string; // Ex: "1º Bimestre"
|
period: string; // Ex: "1º Bimestre"
|
||||||
|
examId?: string; // ID da avaliação vinculada (se houver)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface Exam (avaliações):
|
||||||
|
```typescript
|
||||||
|
interface Exam {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
evaluationType: 'exam' | 'activity';
|
||||||
|
maxScore: number;
|
||||||
|
subjectId?: string;
|
||||||
|
periodId?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, async (req, res) =
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachment = null;
|
let attachment = null;
|
||||||
try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) {}
|
try { const parsed = JSON.parse(justification); attachment = parsed.arquivo_base64 || null; } catch (e) { }
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: `notif-${Date.now()}`, studentId: 'admin',
|
id: `notif-${Date.now()}`, studentId: 'admin',
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,14 @@ app.get('/api/portal/notas', authMiddleware, async (req, res) => {
|
||||||
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
|
const courseSubjects = subjects.filter(s => !s.classId || s.classId === student?.classId);
|
||||||
const enrichedGrades = grades.map((g) => {
|
const enrichedGrades = grades.map((g) => {
|
||||||
const subject = subjects.find((s) => s.id === g.subjectId);
|
const subject = subjects.find((s) => s.id === g.subjectId);
|
||||||
return { ...g, subjectName: subject?.name || 'Disciplina desconhecida' };
|
const exam = g.examId ? (schoolData.exams || []).find(e => e.id === g.examId) : null;
|
||||||
|
return {
|
||||||
|
...g,
|
||||||
|
subjectName: subject?.name || 'Disciplina desconhecida',
|
||||||
|
examTitle: exam?.title,
|
||||||
|
evaluationType: exam?.evaluationType || 'exam',
|
||||||
|
maxScore: exam?.maxScore
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const periods = [...new Set(grades.map((g) => g.period))];
|
const periods = [...new Set(grades.map((g) => g.period))];
|
||||||
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
|
if (periods.length === 0) periods.push('1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre');
|
||||||
|
|
@ -308,7 +315,7 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
|
||||||
}
|
}
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: `notif-${Date.now()}`,
|
id: `notif-${Date.now()}`,
|
||||||
studentId: 'admin',
|
studentId: 'admin',
|
||||||
fromStudentId: req.user.studentId, // Identificador para navegação no Manager
|
fromStudentId: req.user.studentId, // Identificador para navegação no Manager
|
||||||
title: 'Nova Justificativa de Falta',
|
title: 'Nova Justificativa de Falta',
|
||||||
|
|
@ -316,8 +323,8 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
|
||||||
text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
|
text: `${student?.name || 'Aluno'} enviou uma justificativa para a aula de ${date}.`,
|
||||||
motivo: motivo.trim()
|
motivo: motivo.trim()
|
||||||
}),
|
}),
|
||||||
attachment: publicUrl,
|
attachment: publicUrl,
|
||||||
read: false,
|
read: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -531,7 +538,8 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
|
||||||
|
|
||||||
const wrongCount = totalQuestions - correctCount;
|
const wrongCount = totalQuestions - correctCount;
|
||||||
const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0;
|
const percentage = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 100).toFixed(2)) : 0;
|
||||||
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * 10).toFixed(2)) : 0;
|
const maxScore = exam.maxScore != null ? Number(exam.maxScore) : 10;
|
||||||
|
const finalScore = totalQuestions > 0 ? parseFloat(((correctCount / totalQuestions) * maxScore).toFixed(2)) : 0;
|
||||||
|
|
||||||
// Salvar no PostgreSQL
|
// Salvar no PostgreSQL
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|
@ -543,11 +551,18 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
|
||||||
// Integrar com grades no school_data
|
// Integrar com grades no school_data
|
||||||
if (exam.subjectId && exam.periodId) {
|
if (exam.subjectId && exam.periodId) {
|
||||||
const grades = schoolData.grades || [];
|
const grades = schoolData.grades || [];
|
||||||
const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId);
|
const existingGradeIndex = grades.findIndex(g => g.studentId === req.user.studentId && g.subjectId === exam.subjectId && g.period === exam.periodId && g.examId === examId);
|
||||||
if (existingGradeIndex >= 0) {
|
if (existingGradeIndex >= 0) {
|
||||||
grades[existingGradeIndex].value = finalScore;
|
grades[existingGradeIndex].value = finalScore;
|
||||||
} else {
|
} else {
|
||||||
grades.push({ id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`, studentId: req.user.studentId, subjectId: exam.subjectId, period: exam.periodId, value: finalScore });
|
grades.push({
|
||||||
|
id: `grade-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
||||||
|
studentId: req.user.studentId,
|
||||||
|
subjectId: exam.subjectId,
|
||||||
|
period: exam.periodId,
|
||||||
|
value: finalScore,
|
||||||
|
examId: examId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
schoolData.grades = grades;
|
schoolData.grades = grades;
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,24 @@ export default function Avaliacoes() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
backgroundColor: (exam as any).evaluationType === 'activity' ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha, #ede9fe)',
|
||||||
|
color: (exam as any).evaluationType === 'activity' ? 'var(--color-info, #0369a1)' : 'var(--color-primary, #6d28d9)',
|
||||||
|
border: `1px solid ${(exam as any).evaluationType === 'activity' ? '#bae6fd' : '#ddd6fe'}`
|
||||||
|
}}>
|
||||||
|
{(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--color-text-secondary)' }}>
|
||||||
|
Vale: {(exam as any).maxScore ?? 10} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: 4, paddingRight: isDone ? 90 : 0 }}>
|
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: 4, paddingRight: isDone ? 90 : 0 }}>
|
||||||
{exam.title}
|
{exam.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -712,7 +730,7 @@ export default function Avaliacoes() {
|
||||||
boxShadow: '0 4px 15px var(--bg-primary-alpha)',
|
boxShadow: '0 4px 15px var(--bg-primary-alpha)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Award size={18} /> Iniciar Prova
|
<Award size={18} /> {(exam as any).evaluationType === 'activity' ? 'Iniciar Atividade' : 'Iniciar Prova'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { BookOpen } from 'lucide-react';
|
import { BookOpen, FileText } from 'lucide-react';
|
||||||
import type { Grade, Subject } from '../types';
|
import type { Grade, Subject } from '../types';
|
||||||
|
|
||||||
interface GradeWithSubject extends Grade {
|
interface GradeWithSubject extends Grade {
|
||||||
subjectName: string;
|
subjectName: string;
|
||||||
|
examTitle?: string;
|
||||||
|
evaluationType?: string;
|
||||||
|
maxScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Notas() {
|
export default function Notas() {
|
||||||
|
|
@ -42,7 +45,6 @@ export default function Notas() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use subjects from course instead of deriving from grades
|
|
||||||
const displaySubjects = allSubjects.length > 0
|
const displaySubjects = allSubjects.length > 0
|
||||||
? allSubjects
|
? allSubjects
|
||||||
: [...new Set(grades.map(g => g.subjectId))].map(id => ({
|
: [...new Set(grades.map(g => g.subjectId))].map(id => ({
|
||||||
|
|
@ -50,23 +52,26 @@ export default function Notas() {
|
||||||
name: grades.find(g => g.subjectId === id)?.subjectName || id
|
name: grades.find(g => g.subjectId === id)?.subjectName || id
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Logic for General Average: Only show if EVERY subject has a grade > 0
|
// General average logic
|
||||||
const isAllGraded = displaySubjects.length > 0 && displaySubjects.every(s => {
|
const validGrades = grades.filter(g => g.value > 0);
|
||||||
const subjectId = typeof s === 'string' ? s : s.id;
|
const totalAvg = displaySubjects.length > 0 && validGrades.length > 0
|
||||||
const subjectGrades = grades.filter(g => g.subjectId === subjectId);
|
? validGrades.reduce((s, g) => s + g.value, 0) / validGrades.length
|
||||||
return subjectGrades.length > 0 && subjectGrades.every(g => g.value > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalAvg = isAllGraded
|
|
||||||
? grades.reduce((s, g) => s + g.value, 0) / (displaySubjects.length || 1)
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const getGradeColor = (value: number) => {
|
const getGradeColor = (value: number, maxScore: number = 10) => {
|
||||||
if (value >= 7) return 'var(--color-success)';
|
const percentage = (value / maxScore) * 10;
|
||||||
if (value >= 5) return 'var(--color-warning)';
|
if (percentage >= 7) return 'var(--color-success)';
|
||||||
|
if (percentage >= 5) return 'var(--color-warning)';
|
||||||
return 'var(--color-danger)';
|
return 'var(--color-danger)';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBgColor = (value: number, maxScore: number = 10) => {
|
||||||
|
const percentage = (value / maxScore) * 10;
|
||||||
|
if (percentage >= 7) return 'var(--bg-success-alpha)';
|
||||||
|
if (percentage >= 5) return 'var(--bg-warning-alpha)';
|
||||||
|
return 'var(--bg-danger-alpha)';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="animate-fade-in" style={{
|
<div className="animate-fade-in" style={{
|
||||||
|
|
@ -79,7 +84,7 @@ export default function Notas() {
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Notas & Boletim</h1>
|
<h1 className="page-title">Notas & Boletim</h1>
|
||||||
<p className="page-subtitle">Acompanhe seu desempenho acadêmico</p>
|
<p className="page-subtitle">Acompanhe seu desempenho detalhado por disciplina</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-card" style={{
|
<div className="glass-card" style={{
|
||||||
|
|
@ -97,9 +102,9 @@ export default function Notas() {
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.1em',
|
||||||
marginBottom: '0.25rem',
|
marginBottom: '0.25rem',
|
||||||
textTransform: 'uppercase'
|
textTransform: 'uppercase'
|
||||||
}}>Média Geral</p>
|
}}>Média Geral (Estimada)</p>
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: isAllGraded ? '3rem' : '1.25rem',
|
fontSize: totalAvg > 0 ? '3rem' : '1.25rem',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
|
@ -107,7 +112,7 @@ export default function Notas() {
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}>
|
}}>
|
||||||
{isAllGraded ? totalAvg.toFixed(1) : 'Aguardando notas...'}
|
{totalAvg > 0 ? totalAvg.toFixed(1) : 'Aguardando notas...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,64 +125,92 @@ export default function Notas() {
|
||||||
<p style={{ fontSize: '0.9375rem' }}>Nenhuma matéria cadastrada no curso</p>
|
<p style={{ fontSize: '0.9375rem' }}>Nenhuma matéria cadastrada no curso</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="glass-card animate-fade-in" style={{ overflow: 'hidden' }}>
|
<div className="stagger-children">
|
||||||
<div style={{ overflowX: 'auto' }}>
|
{displaySubjects.map((subject, idx) => {
|
||||||
<table className="data-table">
|
const subjectId = typeof subject === 'string' ? subject : subject.id;
|
||||||
<thead>
|
const subjectName = typeof subject === 'string' ? subject : subject.name;
|
||||||
<tr>
|
const subjectGrades = grades.filter(g => g.subjectId === subjectId);
|
||||||
<th>Disciplina</th>
|
|
||||||
<th style={{ textAlign: 'center' }}>Nota / Média</th>
|
return (
|
||||||
</tr>
|
<div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
|
||||||
</thead>
|
<div style={{ padding: '1.5rem', background: 'var(--color-surface-light)', borderBottom: '1px solid var(--glass-border)' }}>
|
||||||
<tbody>
|
<h2 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 800, color: 'var(--color-primary)' }}>{subjectName}</h2>
|
||||||
{displaySubjects.map((s, idx) => {
|
</div>
|
||||||
const subjectId = typeof s === 'string' ? s : s.id;
|
|
||||||
const subjectName = typeof s === 'string' ? s : s.name;
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
{periods.map(period => {
|
||||||
|
const periodGrades = subjectGrades.filter(g => g.period === period);
|
||||||
|
if (periodGrades.length === 0) return null;
|
||||||
|
|
||||||
const subjectGrades = grades.filter(g => g.subjectId === subjectId);
|
const periodTotal = periodGrades.reduce((sum, g) => sum + g.value, 0);
|
||||||
const avg = subjectGrades.length > 0
|
|
||||||
? subjectGrades.reduce((sum, g) => sum + g.value, 0) / subjectGrades.length
|
return (
|
||||||
: 0;
|
<div key={period} style={{ marginBottom: '1.5rem' }}>
|
||||||
return (
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem', borderBottom: '2px solid var(--glass-border)', paddingBottom: '0.5rem' }}>
|
||||||
<tr key={subjectId} style={{
|
<h3 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 800, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-secondary)' }}>{period}</h3>
|
||||||
animation: `fadeIn 0.3s ease-out ${idx * 0.05}s forwards`,
|
<div style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--color-text)' }}>
|
||||||
opacity: 0,
|
Total do Período: <span style={{ color: getGradeColor(periodTotal, 10) }}>{periodTotal.toFixed(1)}</span>
|
||||||
}}>
|
</div>
|
||||||
<td style={{ fontWeight: 500 }}>
|
</div>
|
||||||
{subjectName}
|
|
||||||
</td>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
<td style={{ textAlign: 'center' }}>
|
{periodGrades.map((grade) => {
|
||||||
{subjectGrades.length > 0 && avg > 0 ? (
|
const isActivity = grade.evaluationType === 'activity';
|
||||||
<span style={{
|
const maxScore = grade.maxScore ?? 10;
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
const isDirect = !grade.examId;
|
||||||
minWidth: 48, height: 32, borderRadius: 8, padding: '0 8px',
|
|
||||||
background: avg >= 7 ? 'var(--bg-success-alpha)' : avg >= 5 ? 'var(--bg-warning-alpha)' : 'var(--bg-danger-alpha)',
|
return (
|
||||||
fontWeight: 700, fontSize: '0.925rem',
|
<div key={grade.id} style={{
|
||||||
color: getGradeColor(avg),
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
}}>
|
padding: '0.75rem 1rem',
|
||||||
{avg.toFixed(1)}
|
background: 'var(--color-surface)',
|
||||||
</span>
|
border: '1px solid var(--glass-border)',
|
||||||
) : (
|
borderRadius: '12px'
|
||||||
<span style={{
|
}}>
|
||||||
padding: '4px 10px',
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
background: 'var(--color-surface-light)',
|
<div style={{
|
||||||
borderRadius: 6,
|
padding: '4px', borderRadius: '6px',
|
||||||
color: 'var(--color-text-secondary)',
|
background: isDirect ? 'var(--bg-warning-alpha)' : isActivity ? 'var(--bg-info-alpha, #e0f2fe)' : 'var(--bg-primary-alpha)',
|
||||||
fontSize: '0.7rem',
|
color: isDirect ? 'var(--color-warning)' : isActivity ? 'var(--color-info, #0369a1)' : 'var(--color-primary)'
|
||||||
fontWeight: 600,
|
}}>
|
||||||
textTransform: 'uppercase',
|
<FileText size={16} />
|
||||||
letterSpacing: '0.025em'
|
</div>
|
||||||
}}>
|
<div>
|
||||||
Aguardando
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--color-text)' }}>
|
||||||
</span>
|
{isDirect ? 'Lançamento Direto (Professor)' : grade.examTitle || 'Avaliação sem título'}
|
||||||
)}
|
</div>
|
||||||
</td>
|
{!isDirect && (
|
||||||
</tr>
|
<div style={{ fontSize: '0.65rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--color-text-secondary)' }}>
|
||||||
);
|
{isActivity ? 'ATIVIDADE' : 'PROVA'} • VALE: {maxScore} PTS
|
||||||
})}
|
</div>
|
||||||
</tbody>
|
)}
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 12px', borderRadius: '8px',
|
||||||
|
background: getBgColor(grade.value, maxScore),
|
||||||
|
color: getGradeColor(grade.value, maxScore),
|
||||||
|
fontWeight: 800, fontSize: '0.9rem'
|
||||||
|
}}>
|
||||||
|
{grade.value.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{subjectGrades.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.85rem', padding: '1rem' }}>
|
||||||
|
Nenhuma nota lançada para esta disciplina.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -189,15 +222,15 @@ export default function Notas() {
|
||||||
}}>
|
}}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-success)' }} />
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-success)' }} />
|
||||||
Aprovado (≥ 7.0)
|
Bom Desempenho
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-warning)' }} />
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-warning)' }} />
|
||||||
Recuperação (5.0 - 6.9)
|
Atenção
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-danger)' }} />
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--color-danger)' }} />
|
||||||
Reprovado (< 5.0)
|
Baixo Desempenho
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue