feat: fix evaluation maxScore persistence, circular branding, PWA installation and implement new summing/averaging grading logic
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m5s Details

This commit is contained in:
Sidney 2026-05-29 19:00:03 -03:00
parent 95f469099c
commit bd4a2ca0bb
17 changed files with 625 additions and 251 deletions

View File

@ -79,5 +79,9 @@
46. **Geração de Contratos Automáticos (Fase SQL-First)**: A geração de contratos no cadastro de alunos deve sincronizar diretamente no banco de dados com `POST /api/contratos` e manter fallbacks no `updateData`. Além disso, para evitar contratos duplicados acidentais, o checkbox `generateContract` deve ser desmarcado por padrão ao editar um aluno, mas marcado por padrão ao cadastrar um novo aluno. O checkbox de "Gerar Taxa" foi removido.
47. **Ficha de Frequência Geral (Roster de Turma) em PDF**: O botão de exportação de PDF na lista de alunos da turma no módulo Registro de Frequência do Manager deve exportar um relatório de frequência geral de todos os alunos ativos da turma, com a contagem total acumulada de presenças (P), faltas (F), justificadas (J) e porcentagem de frequência (%) de forma a bater perfeitamente com os dados exibidos na tela, evitando PDFs vazios ou em branco para datas sem aulas.
48. **Pré-Matrícula - Folder Promocional, Rascunho e Duplicatas**: O painel de administração permite que o administrador faça o upload de um folder/banner de promoção (flyer) que é salvo diretamente no bucket `prematriculas` do MinIO. Na página pública de pré-matrícula, se configurado, esse banner é renderizado de forma responsiva acima do formulário. Se o formulário estiver em modo rascunho (`draft`), a página pública oculta todas as turmas e exibe uma mensagem polida informando que todas as vagas estão lotadas e novas vagas abrirão em breve. Inscrições duplicadas são bloqueadas via validação por e-mail e telefone higienizado no backend.
48. **Pré-Matrícula - Folder Promocional Dinâmico (Banner), Rascunho e Duplicatas**: O banner de promoção (flyer) deve ser gerenciado de forma 100% dinâmica como um campo personalizado na lista de campos do formulário (tipo `banner`). Isso permite que o administrador altere a ordem de exibição do flyer em relação aos demais campos e o exclua/adicione livremente. Se o formulário estiver em modo rascunho (`draft`), a página pública oculta as turmas e exibe uma mensagem elegante de vagas esgotadas. Duplicatas são bloqueadas no backend.
49. **Pré-Matrícula - Soft Delete de Campos e Preservação de Histórico**: É estritamente proibido realizar exclusões permanentes (hard-delete) de campos personalizados do formulário de pré-matrícula para evitar a exibição de códigos UUID orfãos na lista de inscrições e na exportação CSV de matrículas antigas. A exclusão de campos deve utilizar Soft Delete (`ativo = false`), e o frontend deve utilizar um mapeador inteligente de fallbacks para recuperar rótulos legados e campos inativos.
50. **Pré-Matrícula - Máscaras de Entrada e Single Event Binding**: Para evitar comportamentos instáveis em teclados móveis (como Gboard no Android ou Autoresumos no iOS), as máscaras de campos estruturados (telefone, CPF, CEP) devem ser gerenciadas por um único escopo de eventos global (`document.addEventListener`). É estritamente proibido realizar vinculações duplas (Double Binding) com ouvintes locais diretos concorrentes nos elementos dinâmicos, para evitar a perda de foco, cursor saltando, ou substituição indesejada de caracteres digitados. A máscara de telefone deve diferenciar e suportar dinamicamente tanto telefones fixos (10 dígitos) quanto celulares (11 dígitos).

View File

@ -70,9 +70,16 @@ Nesta sessão de trabalho, realizamos o "core" da transição do sistema EduMana
- **Relatório de Frequência Geral em PDF (Manager)**: Refatoração do exportador de PDF no módulo Registro de Frequência para gerar uma Ficha de Frequência Geral (Roster de todos os alunos ativos da turma, com contagem acumulada de presenças, faltas, justificativas e porcentagem de frequência de cada um) de modo a corresponder perfeitamente ao que o administrador visualiza na modal, eliminando downloads vazios.
### Fase 9: Endurecimento da Pré-Matrícula, Upload de Panfleto Promocional e Proteção contra Duplicatas
- **Upload de Folder Promocional (MinIO)**: Adição da coluna `banner_url` na tabela SQL `prematricula_config`. Criada a área de upload no painel do administrador para subir folders promocionais diretamente para a pasta `prematriculas` no MinIO Storage local, com pré-visualização em tempo real e remoção inteligente.
- **Renderização Pública Premium**: O folder promocional carregado pelo administrador é renderizado em destaque no topo do formulário público com cantos arredondados, limite de tamanho responsivo e sombras sutis.
- **Folder Promocional Dinâmico e Reordenável (Banner)**: Refatorado o folder promocional (flyer) para ser tratado de forma 100% dinâmica como um campo personalizado especial (`tipo: 'banner'`) dentro do construtor de formulários. O administrador pode fazer o upload da imagem promocional diretamente no construtor (no modal de novo campo ou de edição), excluir e reordenar sua posição de exibição livremente em relação aos outros campos.
- **Renderização Pública Inline Premium**: A página pública de pré-matrícula renderiza a imagem do banner inline de forma premium, responsiva e exatamente na posição sequencial configurada na listagem de campos do banco.
- **Prevenção de Duplicatas e Spam**: Bloqueio de envios duplicados no backend comparando e-mail e telefone limpos de caracteres não-numéricos (para total equivalência).
- **Tratamento de Estado Rascunho (`draft`)**: Se a configuração estiver em rascunho, exibe automaticamente uma página informativa de "Vagas Lotadas - Novas vagas em breve" para proteger a integridade operacional antes do lançamento oficial.
- **Gestão de Campos Personalizados com Arquivos**: Implementação de tipo `file` para campos customizados no formulário do aluno, com upload assíncrono transparente via `FormData` e exibição em formato de hiperlink nos detalhes da inscrição para o administrador.
- **Preservação de Histórico de Campos Personalizados (Soft Delete)**: Correção de bug onde a exclusão de um campo (ex: Sexo) fazia com que o rótulo do campo sumisse e exibisse o código UUID bruto na listagem de inscritos e na exportação de CSV. Implementado Soft Delete (`ativo = false`) na exclusão e criado um mapeador inteligente com dicionário de fallbacks (`getFriendlyLabel`) que recupera os rótulos de campos excluídos ou legados em tempo real.
- **Correção de Máscaras de Entrada (Telefone/CPF/CEP)**: Correção de bug em teclados móveis (Android Gboard/iOS Safari) onde caracteres digitados desapareciam ou geravam loops repetitivos (ex: `(( () ()- 0-`). O problema era causado por double event binding (dois ouvintes `input` concorrentes ativos no mesmo elemento). Centralizada a máscara no ouvinte global de formulário e aprimorada a máscara de telefone para suportar nativamente tanto números fixos (10 dígitos: `(XX) XXXX-XXXX`) quanto móveis (11 dígitos: `(XX) XXXXX-XXXX`).
- **Exclusão Automática Pós-Conversão**: Implementada a exclusão automática da inscrição de pré-matrícula quando a mesma é concluída com sucesso e convertida em matrícula ativa de aluno no sistema. Captura o ID da pré-matrícula original de forma resiliente e dispara uma requisição `DELETE` contra `/api/prematricula/inscricoes/:id`, limpando instantaneamente a lista de pendências da aba de Pré-Matrícula sem requerer intervenção manual.
- **Favicon Dinâmico e Logos Arredondados**: Implementação de crop dinâmico em tempo de execução usando canvas HTML5 para gerar favicons perfeitamente circulares (`rounded-full`) a partir do logotipo cadastrado nas configurações da escola. O favicon circular dinâmico é atualizado automaticamente na aba do navegador tanto no painel administrativo do gestor quanto na página pública de pré-matrícula. Adicionalmente, todos os logotipos exibidos no painel do administrador (Sidebar) e na página pública foram estilizados com bordas elegantes, sombras sutis e arredondamento perfeito (`rounded-full w-8 h-8 object-cover`), elevando o alinhamento visual e a identidade estética premium da plataforma.
- **Favicon Dinâmico e Suporte PWA no Portal do Aluno**: Estendi a geração de favicons perfeitamente circulares (`rounded-full`) via canvas HTML5 para o Portal do Aluno, assegurando correspondência e marca unificada em toda a experiência do estudante. O logotipo da escola também foi estilizado como um círculo perfeito (`rounded-full object-cover`) na barra lateral (`Sidebar.tsx`) e na tela de Login do Portal (`Login.tsx`). Além disso, implementei suporte nativo e dinâmico a PWA (Progressive Web App) no Portal do Aluno: a API agora gera dinamicamente o arquivo `manifest.json` com o nome personalizado e logotipo da escola, registra o Service Worker (`sw.js`) em segundo plano para atender aos critérios de instalação de navegadores e renderiza um botão premium de glassmorphism verde pulsante e micro-animado ("Instalar App") na Sidebar no momento em que o aplicativo se torna instalável, permitindo aos alunos instalar o EduManager em seus celulares/computadores como um aplicativo nativo.
- **Correção da Persistência do Valor (Pontuação Máxima) das Avaliações**: Corrigido bug crítico no módulo de avaliações (Provas/Atividades) onde alterações na pontuação máxima ("Valor" ou `maxScore`) não eram salvas no banco de dados e resetavam de volta para 10.0 ao recarregar a página. A falha ocorria devido à ausência da coluna `max_score` nas operações de INSERT e UPDATE da camada de serviço (`database.js`), bem como a falta de mapeamento do campo no mapeador de retorno (`getProvas`) e no estado do React (`Exams.tsx` e `ReportCard.tsx`). Mapeei dinamicamente a propriedade `maxScore` em todos os fluxos da aplicação (Manager e Portal do Aluno), encapsulada pela conversão estrita `Number(p.max_score)` em conformidade com a Regra 19 de integridade numérica, garantindo que as pontuações e notas correspondentes no Boletim Escolar e Portal do Aluno reflitam precisamente o valor personalizado no banco de dados.
- **Nova Regra de Cálculo de Notas (Soma Capped 10 & Média por Período)**: Refatorei por completo a lógica de cálculo de notas do boletim escolar no Manager (`ReportCard.tsx`) e no Portal do Aluno (`Notas.tsx`). Agora, a nota de um aluno em um período para uma determinada disciplina é calculada pela **SOMA** das notas obtidas em todas as avaliações (provas e atividades) vinculadas àquele período, **limitada ao valor máximo de 10.0** (cap a 10). A Média Geral do aluno passou a ser calculada a nível de período: primeiro é calculada a média aritmética de todas as disciplinas daquele período específico e, caso existam múltiplos períodos com notas, a Média Geral do aluno é calculada pela média aritmética dos períodos lançados; caso exista apenas um único período ativo, a média geral é a média aritmética das disciplinas daquele único período.

View File

@ -41,6 +41,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
allowRetake: p.allowRetake ?? p.permitir_refacao ?? false,
isDeleted: p.isDeleted ?? p.is_deleted ?? false,
evaluationType: p.evaluationType || p.evaluation_type || 'exam',
maxScore: p.maxScore !== undefined && p.maxScore !== null ? Number(p.maxScore) : (p.max_score !== undefined && p.max_score !== null ? Number(p.max_score) : 10),
questions: p.questions || []
})));
}

View File

@ -15,7 +15,22 @@ interface Props {
const FIELD_TYPES: Record<string, string> = {
text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF',
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número',
file: 'Imagem/Documento'
banner: 'Folder / Panfleto de Promoção (Banner)'
};
const getFriendlyLabel = (key: string, value: any): string => {
if (key === 'da80bac4-4018-4ee9-a9c4-a25551411269' || key === 'da80bac4-4018-4ee9-a9c4-a25551411269'.replace(/-/g, '')) return 'Sexo';
if (key === '09e08119-02e1-49cf-8a48-eb7b51a35758' || key === '09e08119-02e1-49cf-8a48-eb7b51a35758'.replace(/-/g, '')) return 'Sexo';
if (key === '82bcc4ce-8ece-4a1b-8131-e64585119cf4' || key === '82bcc4ce-8ece-4a1b-8131-e64585119cf4'.replace(/-/g, '')) return 'CPF';
const valStr = String(value || '');
if (valStr === 'Feminino' || valStr === 'Masculino' || valStr === 'Outro') {
return 'Sexo';
}
if (/^\d{11}$/.test(valStr) || /^\d{3}\.\d{3}\.\d{3}-\d{2}$/.test(valStr)) {
return 'CPF';
}
return 'Campo Excluído';
};
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
@ -23,6 +38,7 @@ const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', '
const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
const [config, setConfig] = useState<PreMatriculaConfig | null>(null);
const [campos, setCampos] = useState<PreMatriculaCampo[]>([]);
const [todosCampos, setTodosCampos] = useState<PreMatriculaCampo[]>([]);
const [inscricoes, setInscricoes] = useState<PreMatriculaInscricao[]>([]);
const [turmas, setTurmas] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState<'editor' | 'inscricoes'>('editor');
@ -44,7 +60,7 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
fetch('/api/turmas')
]);
if (cfgRes.ok) { const j = await cfgRes.json(); setConfig(j.config); }
if (camposRes.ok) { const j = await camposRes.json(); setCampos(j.campos || []); }
if (camposRes.ok) { const j = await camposRes.json(); setCampos(j.campos || []); setTodosCampos(j.todosCampos || j.campos || []); }
if (inscRes.ok) { const j = await inscRes.json(); setInscricoes(j.inscricoes || []); }
if (turmasRes.ok) { const j = await turmasRes.json(); setTurmas(j.turmas || []); }
} catch (e) { console.error(e); }
@ -156,7 +172,10 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
if (filtered.length === 0) return;
const allKeys = new Set<string>();
filtered.forEach(i => Object.keys(i.respostas || {}).forEach(k => allKeys.add(k)));
const headers = ['Nome', 'Email', 'Telefone', 'Turma', 'Status', 'Data', ...Array.from(allKeys)];
const headers = ['Nome', 'Email', 'Telefone', 'Turma', 'Status', 'Data', ...Array.from(allKeys).map(k => {
const field = todosCampos.find(c => c.id === k);
return field ? field.label : getFriendlyLabel(k, null);
})];
const rows = filtered.map(i => [
i.nome, i.email, i.telefone, i.turmaNome, i.status,
new Date(i.createdAt).toLocaleDateString('pt-BR'),
@ -388,61 +407,6 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado).
</p>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
{config?.bannerUrl ? (
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
<img src={config.bannerUrl} alt="Banner" className="w-full h-auto max-h-96 object-contain bg-slate-50" />
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setConfig(prev => prev ? { ...prev, bannerUrl: '' } : prev)}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-[11px] font-bold rounded-lg shadow transition-all active:scale-95"
>
Remover Folder
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center w-full mb-3">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100/70 border-slate-300 transition-all">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 text-slate-400 mb-2" />
<p className="text-xs text-slate-500 font-bold">Upload do Folder/Flyer</p>
<p className="text-[9px] text-slate-400 mt-1">PNG, JPG ou WEBP até 10MB</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/prematricula/upload', {
method: 'POST',
body: formData
});
if (res.ok) {
const result = await res.json();
setConfig(prev => prev ? { ...prev, bannerUrl: result.url } : prev);
} else {
alert('Falha ao enviar o folder.');
}
} catch (err) {
console.error(err);
alert('Erro de conexão ao enviar.');
}
}}
/>
</label>
</div>
)}
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label>
<textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={2}
@ -499,6 +463,52 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{newField.tipo === 'banner' ? (
<div className="col-span-2">
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
{newField.placeholder ? (
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<img src={newField.placeholder} alt="Banner" className="w-full h-24 object-contain bg-slate-50" />
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
onClick={() => setNewField(prev => ({ ...prev, placeholder: '' }))}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-[10px] font-bold rounded-lg transition-all"
>
Remover Folder
</button>
</div>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-24 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-white hover:bg-slate-50 border-slate-300 transition-all">
<div className="flex flex-col items-center justify-center pt-3 pb-3">
<Upload className="w-6 h-6 text-slate-400 mb-1" />
<p className="text-[10px] text-slate-500 font-bold">Upload do Folder/Flyer</p>
<p className="text-[8px] text-slate-400 mt-0.5">PNG, JPG ou WEBP até 10MB</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/prematricula/upload', { method: 'POST', body: formData });
if (res.ok) {
const result = await res.json();
setNewField(prev => ({ ...prev, placeholder: result.url }));
}
} catch (err) { console.error(err); }
}}
/>
</label>
)}
</div>
) : (
<>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Placeholder</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
@ -511,6 +521,8 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
placeholder="Opção 1, Opção 2, Opção 3" value={newField.opcoes} onChange={e => setNewField({ ...newField, opcoes: e.target.value })} />
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm font-bold text-slate-600 cursor-pointer">
@ -631,6 +643,51 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
</select>
</div>
{editingCampo.tipo === 'banner' ? (
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
{editingCampo.placeholder ? (
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<img src={editingCampo.placeholder} alt="Banner" className="w-full h-24 object-contain bg-slate-50" />
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
onClick={() => setEditingCampo(prev => prev ? { ...prev, placeholder: '' } : null)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-[10px] font-bold rounded-lg transition-all"
>
Remover Folder
</button>
</div>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-24 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100 border-slate-300 transition-all">
<div className="flex flex-col items-center justify-center pt-3 pb-3">
<Upload className="w-6 h-6 text-slate-400 mb-1" />
<p className="text-[10px] text-slate-500 font-bold">Upload do Folder/Flyer</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/prematricula/upload', { method: 'POST', body: formData });
if (res.ok) {
const result = await res.json();
setEditingCampo(prev => prev ? { ...prev, placeholder: result.url } : null);
}
} catch (err) { console.error(err); }
}}
/>
</label>
)}
</div>
) : (
<>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Placeholder</label>
<input
@ -650,6 +707,8 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
/>
</div>
)}
</>
)}
<label className="flex items-center gap-2.5 text-sm font-bold text-slate-600 cursor-pointer py-1">
<input
@ -733,8 +792,8 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
{insc.respostas && Object.keys(insc.respostas).length > 0 && (
<div className="mt-2 space-y-1 bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-[11px] max-w-xs">
{Object.entries(insc.respostas).map(([key, value]) => {
const field = campos.find(c => c.id === key);
const label = field ? field.label : key;
const field = todosCampos.find(c => c.id === key);
const label = field ? field.label : getFriendlyLabel(key, value);
const isUrl = typeof value === 'string' && (value.startsWith('http') || value.startsWith('/storage/'));
return (
<div key={key} className="flex flex-col sm:flex-row sm:items-center gap-1">

View File

@ -74,6 +74,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
periodId: p.periodId || p.periodo_id,
title: p.title || p.titulo,
evaluationType: p.evaluationType || p.evaluation_type || 'exam',
maxScore: p.maxScore !== undefined && p.maxScore !== null ? Number(p.maxScore) : (p.max_score !== undefined && p.max_score !== null ? Number(p.max_score) : 10),
isDeleted: p.isDeleted ?? p.is_deleted ?? false
})));
}
@ -339,28 +340,38 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
};
const calculateGeneralAverage = () => {
let totalSum = 0;
let totalCount = 0;
const periodAverages: Record<string, number> = {};
const periodsWithGrades = new Set<string>();
Object.entries(studentGrades).forEach(([subjectId, subjectPeriods]) => {
const periodAvgs: number[] = [];
periods.forEach(period => {
let sumForPeriod = 0;
let countForPeriod = 0;
Object.values(subjectPeriods).forEach((examValues: any) => {
const validValues = Object.values(examValues).filter(v => v !== '');
subjects.forEach(subject => {
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const validValues = Object.values(periodGrades).filter(v => v !== '');
if (validValues.length > 0) {
const sum: number = validValues.reduce<number>((a, b: any) => a + Number(b), 0);
periodAvgs.push(sum / validValues.length);
const subjectPeriodGrade = Math.min(10, validValues.reduce<number>((a, b: any) => a + Number(b), 0));
sumForPeriod += subjectPeriodGrade;
countForPeriod++;
}
});
if (periodAvgs.length > 0) {
const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length;
totalSum += subjectAvg;
totalCount++;
if (countForPeriod > 0) {
periodAverages[period.id] = sumForPeriod / countForPeriod;
periodsWithGrades.add(period.id);
}
});
return totalCount > 0 ? (totalSum / totalCount).toFixed(2) : '0.00';
if (periodsWithGrades.size === 0) return '0.00';
if (periodsWithGrades.size === 1) {
const singlePeriodId = Array.from(periodsWithGrades)[0];
return periodAverages[singlePeriodId].toFixed(2);
}
const sumOfPeriods = Array.from(periodsWithGrades).reduce((acc, pId) => acc + periodAverages[pId], 0);
return (sumOfPeriods / periodsWithGrades.size).toFixed(2);
};
const getStudentGeneralAverage = (studentId: string) => {
@ -371,35 +382,39 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
if (studentGradesList.length === 0) return '0.00';
const subjectAverages: number[] = [];
const subjectsWithGrades = new Set(studentGradesList.map(g => g.subjectId));
const periodAverages: Record<string, { sum: number, count: number }> = {};
const subjectPeriodGrades: Record<string, Record<string, number[]>> = {};
subjectsWithGrades.forEach(subId => {
const subGrades = studentGradesList.filter(g => g.subjectId === subId);
const periodValues: Record<string, number[]> = {};
subGrades.forEach(g => {
if (!periodValues[g.period]) periodValues[g.period] = [];
periodValues[g.period].push(g.value);
studentGradesList.forEach(g => {
if (!subjectPeriodGrades[g.subjectId]) subjectPeriodGrades[g.subjectId] = {};
if (!subjectPeriodGrades[g.subjectId][g.period]) subjectPeriodGrades[g.subjectId][g.period] = [];
subjectPeriodGrades[g.subjectId][g.period].push(g.value);
});
const periodAvgs: number[] = [];
Object.values(periodValues).forEach(values => {
Object.entries(subjectPeriodGrades).forEach(([subjectId, periodMap]) => {
Object.entries(periodMap).forEach(([periodId, values]) => {
if (values.length > 0) {
const sum = values.reduce((a, b) => a + b, 0);
periodAvgs.push(sum / values.length);
const subjectPeriodSum = Math.min(10, values.reduce((a, b) => a + b, 0));
if (!periodAverages[periodId]) {
periodAverages[periodId] = { sum: 0, count: 0 };
}
periodAverages[periodId].sum += subjectPeriodSum;
periodAverages[periodId].count++;
}
});
if (periodAvgs.length > 0) {
const totalSum = periodAvgs.reduce((a, b) => a + b, 0);
subjectAverages.push(totalSum / periodAvgs.length);
}
});
if (subjectAverages.length === 0) return '0.00';
const totalSum = subjectAverages.reduce((a, b) => a + b, 0);
return (totalSum / subjectAverages.length).toFixed(2);
const activePeriods = Object.keys(periodAverages);
if (activePeriods.length === 0) return '0.00';
const periodAvgValues = activePeriods.map(pId => periodAverages[pId].sum / periodAverages[pId].count);
if (activePeriods.length === 1) {
return periodAvgValues[0].toFixed(2);
}
const totalSum = periodAvgValues.reduce((a, b) => a + b, 0);
return (totalSum / activePeriods.length).toFixed(2);
};
const filteredClasses = dbClasses.filter(c =>
@ -707,7 +722,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
Object.values(subjectGrades).forEach((exVals: any) => {
const validValues = Object.values(exVals).filter(v => v !== '');
if (validValues.length > 0) {
pAvgs.push(validValues.reduce<number>((a, b: any) => a + Number(b), 0) / validValues.length);
pAvgs.push(Math.min(10, validValues.reduce<number>((a, b: any) => a + Number(b), 0)));
}
});
return pAvgs.length > 0 ? (pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length).toFixed(1) : '0.0';
@ -724,7 +739,7 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const validPeriodValues = Object.values(periodGrades).filter(v => v !== '');
const periodAvg: number = validPeriodValues.length > 0
? validPeriodValues.reduce<number>((a, b: any) => a + Number(b), 0) / validPeriodValues.length
? Math.min(10, validPeriodValues.reduce<number>((a, b: any) => a + Number(b), 0))
: 0;
return (

View File

@ -68,7 +68,7 @@ const Sidebar: React.FC<SidebarProps> = ({ currentView, setView, user, logo, onL
{/* Mobile Toggle */}
<div className="md:hidden fixed top-0 left-0 right-0 bg-white border-b border-slate-200 p-4 flex justify-between items-center z-40">
<h1 className="text-xl font-bold text-indigo-600 flex items-center gap-2">
{logo ? <img src={logo} alt="Logo" className="h-8 w-auto object-contain" /> : <BookOpen size={24} />}
{logo ? <img src={logo} alt="Logo" className="h-8 w-8 rounded-full object-cover" /> : <BookOpen size={24} />}
<span>EduManager</span>
</h1>
<button onClick={toggleMobile} className="p-2 text-slate-600">
@ -93,11 +93,11 @@ const Sidebar: React.FC<SidebarProps> = ({ currentView, setView, user, logo, onL
<div className={`p-6 border-b border-slate-200 flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{(!isCollapsed || isMobileOpen) && (
<h1 className="text-xl font-bold text-indigo-600 flex items-center gap-2 overflow-hidden whitespace-nowrap">
{logo ? <img src={logo} alt="Logo" className="h-8 w-auto object-contain flex-shrink-0" /> : <BookOpen size={24} className="flex-shrink-0" />}
{logo ? <img src={logo} alt="Logo" className="h-8 w-8 rounded-full object-cover flex-shrink-0" /> : <BookOpen size={24} className="flex-shrink-0" />}
<span>EduManager</span>
</h1>
)}
{isCollapsed && !isMobileOpen && (logo ? <img src={logo} alt="Logo" className="h-8 w-auto object-contain" /> : <BookOpen size={24} className="text-indigo-600" />)}
{isCollapsed && !isMobileOpen && (logo ? <img src={logo} alt="Logo" className="h-8 w-8 rounded-full object-cover" /> : <BookOpen size={24} className="text-indigo-600" />)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}

View File

@ -40,6 +40,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
const [cancellationReason, setCancellationReason] = useState('');
const [selectedClassId, setSelectedClassId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [convertedPreMatriculaId, setConvertedPreMatriculaId] = useState<string | null>(null);
const [dbStudents, setDbStudents] = useState<any[]>([]);
@ -141,6 +142,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
}
if (deepLinkPreMatricula) {
setEditingStudent(null);
setConvertedPreMatriculaId(deepLinkPreMatricula.id || null);
const parsedData = {
name: deepLinkPreMatricula.nome || '',
@ -776,6 +778,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
setIsClosing(false);
setEditingStudent(null);
setPhotoFile(null); // Reset MinIO physical upload file
setConvertedPreMatriculaId(null);
setFormData({
name: '',
email: '',
@ -1077,6 +1080,15 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
}
await loadStudents();
if (convertedPreMatriculaId) {
try {
await fetch(`/api/prematricula/inscricoes/${convertedPreMatriculaId}`, { method: 'DELETE' });
} catch (err) {
console.error('Erro ao excluir pré-matrícula convertida:', err);
}
}
showAlert('Sucesso', (formData as any).generateFee ? 'Aluno salvo e nova cobrança gerada com sucesso.' : 'Aluno salvo com sucesso.', 'success');
closeModal();
} catch (error) {

View File

@ -133,10 +133,35 @@ const App = () => {
};
}, [data, isCloudEnabled]);
// 3. Dynamic Favicon
// 3. Dynamic Rounded Favicon (Canvas cropped for perfect aesthetic alignment)
useEffect(() => {
const logoUrl = data.logo;
if (logoUrl) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const size = Math.min(img.width, img.height);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, (img.width - size) / 2, (img.height - size) / 2, size, size, 0, 0, size, size);
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = canvas.toDataURL('image/png');
}
} catch (e) {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
@ -146,6 +171,19 @@ const App = () => {
}
link.href = logoUrl;
}
};
img.onerror = () => {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = logoUrl;
};
img.src = logoUrl;
}
}, [data.logo]);
// 4. Efeito para Realtime (Escuta mudanças do Portal em tempo real)
const dataRef = useRef(data);

View File

@ -79,7 +79,7 @@ function getPreMatriculaHTML(slug) {
body{font-family:'Inter',sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.container{max-width:640px;width:100%;background:#fff;border-radius:24px;box-shadow:0 25px 50px -12px rgba(0,0,0,.1);overflow:hidden}
.header{padding:40px 32px 24px;text-align:center}
.header img{max-height:180px;max-width:280px;width:auto;height:auto;margin:0 auto 24px;display:block;object-fit:contain}
.header img{width:96px;height:96px;border-radius:50%;margin:0 auto 24px;display:block;object-fit:cover;border:3px solid var(--primary,#4f46e5);box-shadow:0 10px 15px -3px rgba(0,0,0,.1)}
.header h1{font-size:28px;font-weight:900;color:#0f172a;margin-bottom:8px}
.header p{font-size:14px;color:#64748b;line-height:1.6}
.form{padding:0 32px 32px}
@ -113,8 +113,10 @@ function maskPhone(v){
if(!v)return "";
v=v.replace(/\D/g,"");
if(v.length>11) v=v.substring(0,11);
if(v.length>6){
if(v.length>10){
return '(' + v.substring(0,2) + ') ' + v.substring(2,7) + '-' + v.substring(7);
} else if(v.length>6){
return '(' + v.substring(0,2) + ') ' + v.substring(2,6) + '-' + v.substring(6);
} else if(v.length>2){
return '(' + v.substring(0,2) + ') ' + v.substring(2);
}
@ -163,6 +165,54 @@ async function init(){
const res=await fetch('/api/prematricula/public/'+slug);
if(!res.ok){app.innerHTML='<div class="error-page"><h2>Formulário indisponível</h2><p>Este formulário não está publicado ou o link é inválido.</p></div>';return}
const data=await res.json();
if(data.escola && data.escola.logo) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = function() {
try {
const canvas = document.createElement('canvas');
const size = Math.min(img.width, img.height);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, (img.width - size) / 2, (img.height - size) / 2, size, size, 0, 0, size, size);
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = canvas.toDataURL('image/png');
}
} catch (e) {
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = data.escola.logo;
}
};
img.onerror = function() {
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = data.escola.logo;
};
img.src = data.escola.logo;
}
if(data.draft){
app.innerHTML='<div class="error-page"><h2>Todas as turmas estão lotadas</h2><p style="margin-top:12px;font-weight:600;color:#64748b">Em breve abriremos novas vagas. Fique atento!</p></div>';
return;
@ -172,11 +222,15 @@ async function init(){
let html='<div class="top-bar"></div><div class="header">';
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
if(c.bannerUrl)html+='<div style="padding:0 32px 24px;text-align:center"><img src="'+c.bannerUrl+'" alt="Banner" style="width:100%;height:auto;border-radius:16px;box-shadow:0 4px 12px rgba(0,0,0,.05);display:block;margin:0 auto"></div>';
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
// Render fields dynamically in their exact database order
data.campos.forEach(f=>{
if(f.tipo==='banner'){
if(f.placeholder){
html+='<div style="text-align:center;margin-bottom:20px"><img src="'+f.placeholder+'" alt="Promoção" style="width:100%;height:auto;border-radius:16px;box-shadow:0 4px 12px rgba(0,0,0,.05);display:block;margin:0 auto"></div>';
}
}else{
html+='<div class="field"><label>'+f.label+(f.obrigatorio?' <span class="req">*</span>':'')+'</label>';
if(f.id==='_nome'){
@ -229,22 +283,20 @@ async function init(){
}
}
html+='</div>';
}
});
html+='<button type="submit" class="btn" id="submitBtn">Enviar Pré-Matrícula</button></form>';
app.innerHTML=html;
// Direct binding for ultra-compatibility on all mobile browsers and WebView sandboxes
// Format any prefilled inputs on load
app.querySelectorAll('[data-type]').forEach(function(el){
const dt = el.getAttribute('data-type');
const applyMask = function() {
if (el.value) {
if(dt==='phone') el.value = maskPhone(el.value);
if(dt==='cpf') el.value = maskCPF(el.value);
if(dt==='cep') el.value = maskCEP(el.value);
};
el.addEventListener('input', applyMask);
el.addEventListener('focus', applyMask);
if (el.value) applyMask();
}
});
window._successMsg=c.mensagemSucesso||'Pré-matrícula enviada com sucesso!';
@ -3059,11 +3111,18 @@ async function startServer() {
// Campos
app.get('/api/prematricula/campos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC');
res.json({ campos: rows.map(r => ({
const { rows } = await pool.query('SELECT * FROM prematricula_campos ORDER BY ordem ASC');
const active = rows.filter(r => r.ativo !== false);
res.json({
campos: active.map(r => ({
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
obrigatorio: r.obrigatorio, opcoes: r.opcoes || [], ordem: r.ordem, ativo: r.ativo
}))});
})),
todosCampos: rows.map(r => ({
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
obrigatorio: r.obrigatorio, opcoes: r.opcoes || [], ordem: r.ordem, ativo: r.ativo
}))
});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
@ -3117,7 +3176,7 @@ async function startServer() {
app.delete('/api/prematricula/campos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM prematricula_campos WHERE id = $1', [req.params.id]);
await pool.query('UPDATE prematricula_campos SET ativo = false WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});

View File

@ -872,6 +872,7 @@ export async function getProvas() {
allowRetake: p.permitir_refacao,
isDeleted: p.is_deleted,
evaluationType: p.evaluation_type || 'exam',
maxScore: p.max_score !== null && p.max_score !== undefined ? Number(p.max_score) : 10.0,
questions: questoesRows.map(q => ({
id: q.id,
examId: q.prova_id,
@ -893,14 +894,15 @@ export async function getQuestoesDaProva(provaId) {
export async function insertProva(p) {
await pool.query(
`INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status, permitir_refacao, is_deleted, evaluation_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
`INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status, permitir_refacao, is_deleted, evaluation_type, max_score)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
p.id, p.turma_id || p.classId, p.disciplina_id || p.subjectId, p.periodo_id || p.periodId,
p.titulo || p.title, p.duracao_minutos || p.durationMinutes || 60, p.status || 'draft',
p.permitir_refacao || p.allowRetake || false,
p.is_deleted || p.isDeleted || false,
p.evaluation_type || p.evaluationType || 'exam'
p.evaluation_type || p.evaluationType || 'exam',
p.maxScore !== undefined && p.maxScore !== null ? Number(p.maxScore) : 10.0
]
);
}
@ -908,14 +910,15 @@ export async function insertProva(p) {
export async function updateProva(id, p) {
await pool.query(
`UPDATE provas SET
turma_id = $1, disciplina_id = $2, periodo_id = $3, titulo = $4, duracao_minutos = $5, status = $6, permitir_refacao = $7, is_deleted = $8, evaluation_type = $9
WHERE id = $10`,
turma_id = $1, disciplina_id = $2, periodo_id = $3, titulo = $4, duracao_minutos = $5, status = $6, permitir_refacao = $7, is_deleted = $8, evaluation_type = $9, max_score = $10
WHERE id = $11`,
[
p.turma_id || p.classId, p.disciplina_id || p.subjectId, p.periodo_id || p.periodId,
p.titulo || p.title, p.duracao_minutos || p.durationMinutes || 60, p.status || 'draft',
p.permitir_refacao || p.allowRetake || false,
p.is_deleted || p.isDeleted || false,
p.evaluation_type || p.evaluationType || 'exam',
p.maxScore !== undefined && p.maxScore !== null ? Number(p.maxScore) : 10.0,
id
]
);

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Portal do Aluno - Acesse suas informações acadêmicas e financeiras" />
<title>Portal do Aluno</title>

View File

@ -890,7 +890,7 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
allowRetake: row.permitir_refacao,
isDeleted: row.is_deleted,
evaluationType: row.evaluation_type,
maxScore: 10,
maxScore: row.max_score !== null && row.max_score !== undefined ? Number(row.max_score) : 10,
questions: questoes.map(q => ({
id: q.id,
text: q.texto,
@ -944,7 +944,7 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
subjectId: examData.disciplina_id,
periodId: examData.periodo_id,
evaluationType: examData.evaluation_type,
maxScore: 10,
maxScore: examData.max_score !== null && examData.max_score !== undefined ? Number(examData.max_score) : 10,
questions: questoes.map(q => ({
id: q.id,
correctOptionIndex: q.indice_correto
@ -1041,6 +1041,69 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
}
});
// ===================================================
// PWA ENDPOINTS (Manifest & Service Worker)
// ===================================================
app.get('/manifest.json', async (req, res) => {
try {
const schoolData = await getSchoolData();
const name = schoolData.profile?.name || 'EduManager';
const logo = normalizeStorageUrl(schoolData.logo) || '/vite.svg';
res.json({
"short_name": name,
"name": `${name} - Portal do Aluno`,
"icons": [
{
"src": logo,
"sizes": "192x192 512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"start_url": "/",
"background_color": "#0f172a",
"theme_color": "#4f46e5",
"display": "standalone",
"orientation": "portrait"
});
} catch (e) {
res.json({
"short_name": "EduManager",
"name": "EduManager - Portal do Aluno",
"icons": [
{
"src": "/vite.svg",
"sizes": "192x192 512x512",
"type": "image/svg+xml",
"purpose": "any"
}
],
"start_url": "/",
"background_color": "#0f172a",
"theme_color": "#4f46e5",
"display": "standalone",
"orientation": "portrait"
});
}
});
app.get('/sw.js', (req, res) => {
res.set('Content-Type', 'application/javascript');
res.send(`
const CACHE_NAME = 'edumanager-portal-v1';
self.addEventListener('install', (e) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (e) => {
e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
});
`);
});
// ===================================================
// SERVE FRONTEND
// ===================================================

View File

@ -2,7 +2,8 @@ import { NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
LayoutDashboard, CreditCard, BookOpen, CalendarCheck, CalendarClock,
FileText, Award, User, LogOut, GraduationCap, X, Menu, ClipboardList
FileText, Award, User, LogOut, GraduationCap, X, Menu, ClipboardList,
Download
} from 'lucide-react';
import { useState, useEffect } from 'react';
import { normalizePhotoUrl } from '../helpers';
@ -24,6 +25,35 @@ export default function Sidebar() {
const [mobileOpen, setMobileOpen] = useState(false);
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
const location = useLocation();
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [isInstallable, setIsInstallable] = useState(false);
useEffect(() => {
const handleBeforeInstallPrompt = (e: any) => {
e.preventDefault();
setDeferredPrompt(e);
setIsInstallable(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstallable(false);
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to the PWA install prompt: ${outcome}`);
setDeferredPrompt(null);
setIsInstallable(false);
};
useEffect(() => {
setMobileOpen(false);
@ -116,7 +146,7 @@ export default function Sidebar() {
alt="EduManager"
style={{
width: desktopCollapsed ? 40 : 44, height: desktopCollapsed ? 40 : 44,
objectFit: 'contain', transition: 'all 0.3s ease', borderRadius: 8,
objectFit: 'cover', transition: 'all 0.3s ease', borderRadius: '50%',
}}
/>
) : (
@ -212,6 +242,37 @@ export default function Sidebar() {
))}
</nav>
{/* Install PWA App Option */}
{isInstallable && (
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
<button
onClick={handleInstallClick}
style={{
width: '100%', display: 'flex', alignItems: 'center',
justifyContent: desktopCollapsed ? 'center' : 'flex-start',
gap: '0.75rem',
padding: desktopCollapsed ? '0.75rem 0' : '0.75rem 1rem',
borderRadius: 12, border: '1px solid var(--color-success)',
background: 'rgba(16, 185, 129, 0.1)', color: 'var(--color-success)',
cursor: 'pointer', fontSize: '0.875rem', fontWeight: 600,
transition: 'all 0.2s ease', fontFamily: 'Inter, sans-serif',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.05)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.2)';
e.currentTarget.style.transform = 'scale(1.02)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.1)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
<Download size={20} />
{!desktopCollapsed && <span>Instalar App</span>}
</button>
</div>
)}
{/* Logout */}
<div style={{ padding: '1rem 0.75rem', borderTop: '1px solid var(--glass-border)' }}>
<button

View File

@ -45,7 +45,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.then(data => {
if (data.logo) {
setSchoolLogo(data.logo);
// Set favicon dynamically
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const size = Math.min(img.width, img.height);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, (img.width - size) / 2, (img.height - size) / 2, size, size, 0, 0, size, size);
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = canvas.toDataURL('image/png');
}
} catch (e) {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
@ -54,6 +78,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
link.href = data.logo;
}
};
img.onerror = () => {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = data.logo;
};
img.src = data.logo;
}
})
.catch(() => {});

View File

@ -17,3 +17,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</BrowserRouter>
</React.StrictMode>
);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err);
});
});
}

View File

@ -79,8 +79,10 @@ export default function Login() {
src={schoolLogo}
alt={schoolName}
style={{
maxWidth: 100, maxHeight: 100, objectFit: 'contain',
marginBottom: '1rem', borderRadius: 12,
width: 96, height: 96, objectFit: 'cover',
marginBottom: '1rem', borderRadius: '50%',
border: '3px solid var(--color-success)',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
/>
) : (

View File

@ -59,37 +59,42 @@ export default function Notas() {
const calculateGeneralAverage = () => {
if (displaySubjects.length === 0 || grades.length === 0) return 0;
let totalSum = 0;
let totalCount = 0;
const periodAverages: Record<string, number> = {};
const periodsWithGrades = new Set<string>();
periods.forEach(period => {
let sumForPeriod = 0;
let countForPeriod = 0;
displaySubjects.forEach(subject => {
const subjectId = typeof subject === 'string' ? subject : subject.id;
const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId));
if (subjectGrades.length === 0) return;
const subjectGrades = grades.filter(g =>
String(g.subjectId) === String(subjectId) &&
String(g.periodName || g.period) === String(period)
);
const periodValues: Record<string, number[]> = {};
subjectGrades.forEach(g => {
const periodKey = String(g.periodName || g.period);
if (!periodValues[periodKey]) periodValues[periodKey] = [];
periodValues[periodKey].push(g.value);
});
const periodAvgs: number[] = [];
Object.values(periodValues).forEach(values => {
if (values.length > 0) {
const sum = values.reduce((a, b) => a + b, 0);
periodAvgs.push(sum / values.length);
if (subjectGrades.length > 0) {
const subjectPeriodGrade = Math.min(10, subjectGrades.reduce((acc, g) => acc + g.value, 0));
sumForPeriod += subjectPeriodGrade;
countForPeriod++;
}
});
if (periodAvgs.length > 0) {
const subjectAvg = periodAvgs.reduce((a, b) => a + b, 0) / periodAvgs.length;
totalSum += subjectAvg;
totalCount++;
if (countForPeriod > 0) {
periodAverages[period] = sumForPeriod / countForPeriod;
periodsWithGrades.add(period);
}
});
return totalCount > 0 ? totalSum / totalCount : 0;
if (periodsWithGrades.size === 0) return 0;
if (periodsWithGrades.size === 1) {
const singlePeriod = Array.from(periodsWithGrades)[0];
return periodAverages[singlePeriod];
}
const sumOfPeriods = Array.from(periodsWithGrades).reduce((acc, p) => acc + periodAverages[p], 0);
return sumOfPeriods / periodsWithGrades.size;
};
const totalAvg = calculateGeneralAverage();
@ -167,20 +172,20 @@ export default function Notas() {
const subjectName = typeof subject === 'string' ? subject : subject.name;
const subjectGrades = grades.filter(g => String(g.subjectId) === String(subjectId));
// Calculate Subject Average
// Calculate Subject Average (SUM capped at 10 for each period, then averaged)
const periodValues: Record<string, number[]> = {};
subjectGrades.forEach(g => {
const periodKey = String(g.periodName || g.period);
if (!periodValues[periodKey]) periodValues[periodKey] = [];
periodValues[periodKey].push(g.value);
});
const pAvgs: number[] = [];
const pSums: number[] = [];
Object.values(periodValues).forEach(values => {
if (values.length > 0) {
pAvgs.push(values.reduce((a, b) => a + b, 0) / values.length);
pSums.push(Math.min(10, values.reduce((a, b) => a + b, 0)));
}
});
const subjectAvg = pAvgs.length > 0 ? pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length : null;
const subjectAvg = pSums.length > 0 ? pSums.reduce((a, b) => a + b, 0) / pSums.length : null;
return (
<div key={subjectId} className="glass-card animate-fade-in" style={{ marginBottom: '1.5rem', overflow: 'hidden' }}>
@ -198,7 +203,7 @@ export default function Notas() {
const periodGrades = subjectGrades.filter(g => String(g.periodName || g.period) === String(period));
if (periodGrades.length === 0) return null;
const periodAvg = periodGrades.length > 0 ? periodGrades.reduce((sum, g) => sum + g.value, 0) / periodGrades.length : 0;
const periodAvg = periodGrades.length > 0 ? Math.min(10, periodGrades.reduce((sum, g) => sum + g.value, 0)) : 0;
return (
<div key={period} style={{ marginBottom: '1.5rem' }}>