From 501751c22f98b4c0b2a2d4281ccb61800044a369 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 28 Nov 2025 16:50:45 -0300 Subject: [PATCH 01/31] feat: improve point registration processing feedback with step-by-step messages and update modal positioning across components. --- apps/web/src/lib/components/ErrorModal.svelte | 16 ++--- .../components/ponto/ComprovantePonto.svelte | 16 ++--- .../lib/components/ponto/RegistroPonto.svelte | 71 ++++++++++++++----- bun.lock | 1 - 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/apps/web/src/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte index a0a2bb4..a0176d0 100644 --- a/apps/web/src/lib/components/ErrorModal.svelte +++ b/apps/web/src/lib/components/ErrorModal.svelte @@ -13,20 +13,20 @@ let modalPosition = $state<{ top: number; left: number } | null>(null); - // Função para calcular a posição baseada no relógio sincronizado + // Função para calcular a posição baseada no card de registro de ponto function calcularPosicaoModal() { - // Procurar pelo elemento do relógio sincronizado - const relogioRef = document.getElementById('relogio-sincronizado-ref'); + // Procurar pelo elemento do card de registro de ponto + const cardRef = document.getElementById('card-registro-ponto-ref'); - if (relogioRef) { - const rect = relogioRef.getBoundingClientRect(); + if (cardRef) { + const rect = cardRef.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - // Posicionar o modal na mesma posição do relógio sincronizado - // Centralizado horizontalmente no card do relógio + // Posicionar o modal na mesma posição do card de registro + // Centralizado horizontalmente no card const left = rect.left + (rect.width / 2); - // Posicionar abaixo do card do relógio com um pequeno espaçamento + // Posicionar abaixo do card com um pequeno espaçamento const top = rect.bottom + 20; return { diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index c93b0ed..6031454 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -21,20 +21,20 @@ let gerando = $state(false); let modalPosition = $state<{ top: number; left: number } | null>(null); - // Função para calcular a posição baseada no relógio sincronizado + // Função para calcular a posição baseada no card de registro de ponto function calcularPosicaoModal() { - // Procurar pelo elemento do relógio sincronizado - const relogioRef = document.getElementById('relogio-sincronizado-ref'); + // Procurar pelo elemento do card de registro de ponto + const cardRef = document.getElementById('card-registro-ponto-ref'); - if (relogioRef) { - const rect = relogioRef.getBoundingClientRect(); + if (cardRef) { + const rect = cardRef.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - // Posicionar o modal na mesma posição do relógio sincronizado - // Centralizado horizontalmente no card do relógio + // Posicionar o modal na mesma posição do card de registro + // Centralizado horizontalmente no card const left = rect.left + (rect.width / 2); - // Posicionar abaixo do card do relógio com um pequeno espaçamento + // Posicionar abaixo do card com um pequeno espaçamento const top = rect.bottom + 20; return { diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 0376321..40170bc 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -65,6 +65,7 @@ let mostrandoModalConfirmacao = $state(false); let dataHoraAtual = $state<{ data: string; hora: string } | null>(null); let aguardandoProcessamento = $state(false); + let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | null>(null); const registrosHoje = $derived(registrosHojeQuery?.data || []); const config = $derived(configQuery?.data); @@ -204,15 +205,19 @@ registrando = true; sucesso = null; coletandoInfo = true; + aguardandoProcessamento = true; + etapaProcessamento = 'coletando'; try { // Coletar informações do dispositivo + etapaProcessamento = 'coletando'; const informacoesDispositivo = await obterInformacoesDispositivo(); // Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias coletandoInfo = false; // Obter tempo sincronizado e aplicar GMT offset (igual ao relógio) + etapaProcessamento = 'sincronizando'; const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido const gmtOffset = configRelogio.gmtOffset ?? 0; @@ -262,6 +267,7 @@ let imagemId: Id<'_storage'> | undefined = undefined; if (imagemCapturada) { try { + etapaProcessamento = 'upload'; imagemId = await uploadImagem(imagemCapturada); } catch (error) { console.error('Erro ao fazer upload da imagem:', error); @@ -272,6 +278,7 @@ } // Registrar ponto + etapaProcessamento = 'registrando'; const resultado = await client.mutation(api.pontos.registrarPonto, { imagemId, informacoesDispositivo, @@ -314,6 +321,7 @@ } catch (error) { console.error('Erro ao registrar ponto:', error); aguardandoProcessamento = false; + etapaProcessamento = null; let mensagemErro = 'Erro desconhecido ao registrar ponto'; let detalhesErro = 'Tente novamente em alguns instantes.'; @@ -392,6 +400,7 @@ registrando = false; coletandoInfo = false; aguardandoProcessamento = false; + etapaProcessamento = null; } } @@ -518,7 +527,11 @@ function confirmarRegistro() { mostrandoModalConfirmacao = false; aguardandoProcessamento = true; - registrarPonto(); + etapaProcessamento = 'coletando'; + // Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro + setTimeout(() => { + registrarPonto(); + }, 100); } function cancelarRegistro() { @@ -855,20 +868,20 @@ // Posicionamento dos modais let modalPosition = $state<{ top: number; left: number } | null>(null); - // Função para calcular a posição baseada no relógio sincronizado + // Função para calcular a posição baseada no card de registro de ponto function calcularPosicaoModal() { - // Procurar pelo elemento do relógio sincronizado - const relogioRef = document.getElementById('relogio-sincronizado-ref'); + // Procurar pelo elemento do card de registro de ponto + const cardRef = document.getElementById('card-registro-ponto-ref'); - if (relogioRef) { - const rect = relogioRef.getBoundingClientRect(); + if (cardRef) { + const rect = cardRef.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - // Posicionar o modal na mesma posição do relógio sincronizado - // Centralizado horizontalmente no card do relógio + // Posicionar o modal na mesma posição do card de registro + // Centralizado horizontalmente no card const left = rect.left + (rect.width / 2); - // Posicionar abaixo do card do relógio com um pequeno espaçamento + // Posicionar abaixo do card com um pequeno espaçamento const top = rect.bottom + 20; return { @@ -1009,7 +1022,7 @@
-
+
@@ -1498,8 +1511,32 @@ >
- -

Por favor, aguarde enquanto processamos seu registro de ponto...

+ +

+ {#if etapaProcessamento === 'coletando'} + Coletando informações do dispositivo e localização... + {:else if etapaProcessamento === 'sincronizando'} + Sincronizando o horário com o servidor... + {:else if etapaProcessamento === 'upload'} + Enviando a foto capturada para o servidor... + {:else if etapaProcessamento === 'registrando'} + Finalizando o registro de ponto no sistema... + {:else} + Por favor, aguarde enquanto processamos seu registro de ponto... + {/if} +

@@ -1540,7 +1577,7 @@ @@ -1658,7 +1695,7 @@
{/if} -
+
-
-
- +
+
+
-

Registrar Ponto

+

Registrar Ponto

-
+
-
- +
+
+ @@ -1817,33 +2073,56 @@
-

Registrar Declaração de Comparecimento

+
+
+ + + +
+
+

Registrar Declaração de Comparecimento

+

Preencha os dados da declaração de comparecimento

+
+
- +
+ +
-
- + Data Início * -
+
-
- Data Fim * -
+
@@ -1863,24 +2142,55 @@
-
- Observações -
+
-
- - + @@ -1891,61 +2201,84 @@
-

Registrar Licença Maternidade

+
+
+ + + +
+
+

Registrar Licença Maternidade

+

Preencha os dados da licença maternidade (120 dias)

+
+
- +
+ +
-
- + Data Início * -
+
-
- Data Fim * -
+ -
- Calculado automaticamente (120 dias) -
+
-
{#if licencaMaternidade.ehProrrogacao}
-
- + Licença Original * -
+
-
- +
+
+ @@ -2007,38 +2371,61 @@
-

Registrar Licença Paternidade

+
+
+ + + +
+
+

Registrar Licença Paternidade

+

Preencha os dados da licença paternidade (20 dias)

+
+
- +
+ +
-
- + Data Início * -
+
-
- Data Fim * -
+ -
- Calculado automaticamente (20 dias) -
+
@@ -2056,21 +2443,38 @@
-
- Observações -
+
-
- +
+
+
-
+
{:else if abaAtiva === 'relatorios'}
-

- - - - Imprimir Relatórios -

+
+
+ + + +
+
+

Imprimir Relatórios

+

Gere relatórios em PDF ou Excel com filtros personalizados

+
+
@@ -2137,7 +2560,7 @@
-
-
+
+ @@ -2169,7 +2593,6 @@ Todos @@ -2218,7 +2641,8 @@
-
+
+
+ + +
+ {#if hasError}
@@ -1204,8 +1879,39 @@
{/if} - -
+ + {#if abaAtiva === 'dashboard'} + + +
+
+
+ + + +
+
+

Dashboard de Férias

+

+ Visão geral de todas as solicitações e funcionários com gráficos e estatísticas +

+
+
+
+ + +
{#if isLoading && !hasError} {#each Array.from({ length: 4 }, (_, i) => i) as index (index)}
@@ -1324,18 +2030,41 @@
-

Filtros

+
+
+ + + +
+
+

Filtros

+

+ Filtre as solicitações de férias para visualizar no dashboard +

+
+
@@ -1351,17 +2080,17 @@ class="text-base-content/80 flex items-center justify-between text-sm font-semibold" > Status - {#if filtroStatus !== 'todos'} + {#if filtroStatusDashboard !== 'todos'} {/if}
- @@ -1387,18 +2116,18 @@

@@ -1419,18 +2148,18 @@

@@ -1451,18 +2180,18 @@

@@ -1483,17 +2212,17 @@

Filtra as solicitações que possuem períodos ativos dentro do mês informado. @@ -1513,8 +2242,8 @@ @@ -1527,8 +2256,8 @@

@@ -1538,8 +2267,8 @@
@@ -1554,114 +2283,22 @@
{/if} - - {#if isLoading && !hasError} -
-
-
-
-
-
- {:else if !hasError} -
-
-
-
- - - -
-
-

Calendário Geral de Férias

-

- Visualize os períodos aprovados diretamente no calendário interativo -

-
-
-
-
+ + {#if isLoading && !hasError} +
+
+
+
-
- {/if} - - - {#if !isLoading || !hasError} -
-
-
-
- - - -
-
-

- Impressão da Programação de Férias -

-

- Escolha o período desejado e gere um relatório pronto para impressão com todos os - colaboradores em férias, incluindo detalhes completos de cada período. -

-
-
-
-
- - -
-
- - -
-
- +
+
+

Calendário Geral de Férias

+

+ Visualize os períodos aprovados diretamente no calendário interativo +

+
+
+
+
-

- O relatório será aberto em uma nova aba com formatação própria para impressão. Verifique - se o bloqueador de pop-ups está desabilitado para o domínio. -

-
- {/if} + {/if} - - {#if isLoading && !hasError} -
-
-
-
- {#each Array.from({ length: 5 }, (_, i) => i) as index (index)} -
- {/each} + + {#if isLoading && !hasError} +
+ {#each Array.from({ length: 3 }, (_, i) => i) as index (index)} +
+
+
+
+
+
+ {/each} +
+ {:else} +
+
+ {/if} + {:else if abaAtiva === 'solicitacoes'} + + +
+
+
+ + + +
+
+

Solicitações de Férias

+

+ Gerencie e visualize todas as solicitações de férias dos funcionários +

- {:else} + + + {#if isLoading && !hasError} +
+
+
+
+ {#each Array.from({ length: 6 }, (_, i) => i) as index (index)} +
+
+
+ {/each} +
+
+
+ {:else} +
+
+
+
+
+ + + +
+
+

Filtros

+

+ Filtre as solicitações para encontrar o que você precisa +

+
+
+ +
+ +
+ +
+
+
+ Status + {#if filtroStatusSolicitacoes !== 'todos'} + + {/if} +
+ +

+ Defina o status das solicitações que deseja visualizar. +

+
+
+ + +
+
+
+ Nome do funcionário + +
+ +

+ Pesquise por nome completo ou parcial para localizar rapidamente um colaborador. +

+
+
+ + +
+
+
+ Matrícula + +
+ +

+ Utilize a matrícula funcional para filtrar solicitações específicas. +

+
+
+ + +
+
+
+ E-mail institucional + +
+ +

+ Busque usando o correio institucional cadastrado na ficha do colaborador. +

+
+
+ + +
+
+
+ Mês de referência + +
+ +

+ Filtra as solicitações que possuem períodos ativos dentro do mês informado. +

+
+
+ + +
+
+
+ Período personalizado + +
+
+
+ Data inicial + +
+
+ Data final + +
+
+

+ Combine as datas para localizar períodos específicos de férias aprovadas ou em + andamento. +

+
+
+
+
+
+ {/if} + + + {#if isLoading && !hasError} +
+
+
+
+ {#each Array.from({ length: 5 }, (_, i) => i) as index (index)} +
+ {/each} +
+
+
+ {:else}

@@ -1816,29 +2762,45 @@

{/if} - - - {#if isLoading && !hasError} -
- {#each Array.from({ length: 3 }, (_, i) => i) as index (index)} -
-
-
-
-
+ {:else if abaAtiva === 'relatorios'} + + +
+
+
+ + +
- {/each} +
+

Imprimir Relatórios

+

+ Configure os filtros e gere relatórios de programação de férias em PDF ou Excel +

+
+
- {:else} -
- -
-
+ + +
+
+
-
+
-

- Dias de Férias Programados por Mês -

+

Filtros

- Somatório de dias planejados considerando a data de início de cada período + Configure os filtros para gerar o relatório personalizado

-
- {#if periodosPorMesAtivos.length === 0} -
-

Sem dados registrados até o momento.

+ +
+ +
+ +
+
+
+ Período +
- {:else} - - {#if periodosPorMes.length > 1} -
-
+
+ Data inicial - Janela exibida - - {periodosPorMes[rangeInicioIndice]?.label ?? '-'} - → - {periodosPorMes[rangeFimIndice]?.label ?? '-'} - -
-
-
- - -
-
- - -
-
-

- Ajuste com o mouse os intervalos exibidos no gráfico. -

+
- {/if} - {/if} +
+ Data final + +
+
+

+ Selecione o período para gerar o relatório de programação de férias. +

+
+
+ + +
+
+
+ Funcionário + +
+ +

+ Filtre por nome do funcionário para gerar relatório específico. +

+
+
+ + +
+
+
+ Matrícula + +
+ +

+ Filtre por matrícula do funcionário para gerar relatório específico. +

+
+
+ + +
+
+
+ Status + {#if filtroStatusRelatorio !== 'todos'} + + {/if} +
+ +

+ Filtre por status das solicitações de férias. +

+
+
+ + +
+
+
+ Mês de referência + +
+ +

+ Filtra as solicitações que possuem períodos ativos dentro do mês informado. +

+
-
- -
-
+
+ + +
-
+
-

- Dias Totais Aprovados por Ano de Referência -

+

Imprimir programação de Férias

- Volume agregado de dias e número de solicitações por ano + Escolha o formato desejado para gerar o relatório de programação de férias

-
- {#if solicitacoesPorAno.length === 0} -
-

- Ainda não há solicitações registradas para exibição. -

-
- {:else} - - {/if} + +
+ +
+

+ Os relatórios serão gerados com base nos filtros selecionados acima. O PDF será aberto para impressão e o Excel será baixado automaticamente. +

{/if} + + + {#if isLoading && !hasError} +
+ {#each Array.from({ length: 3 }, (_, i) => i) as index (index)} +
+
+
+
+
+
+ {/each} +
+ {:else} +
+
+ {/if} diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index a89e9d0..0c16830 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -90,14 +90,33 @@ export const listarTodas = query({ .first(); let time = null; + let gestor = null; if (membroTime) { time = await ctx.db.get(membroTime.timeId); + // Buscar gestor do time + if (time?.gestorId) { + const gestorUsuario = await ctx.db.get(time.gestorId); + if (gestorUsuario) { + // Buscar funcionário do gestor para obter o nome + const gestorFuncionario = await ctx.db + .query("funcionarios") + .withIndex("by_usuario", (q) => q.eq("usuarioId", time.gestorId)) + .first(); + if (gestorFuncionario) { + gestor = { + _id: gestorUsuario._id, + nome: gestorFuncionario.nome, + }; + } + } + } } return { ...ferias, funcionario, time, + gestor, }; }) ); From 1d9f924cb825a438d018af7c82533db650a15ba7 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 29 Nov 2025 20:30:35 -0300 Subject: [PATCH 09/31] feat: add employee profile picture retrieval to leave report, updating gestor information and table headers for improved clarity --- .../recursos-humanos/ferias/+page.svelte | 32 ++++++++++++------- packages/backend/convex/ferias.ts | 14 +++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte index 7bd8a6f..666feb8 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte @@ -277,14 +277,14 @@ ); const periodosDetalhados = $derived>( - solicitacoesAprovadas + solicitacoesAprovadas .map((periodo) => ({ feriasId: periodo._id, funcionarioId: periodo.funcionarioId, anoReferencia: periodo.anoReferencia, funcionarioNome: periodo.funcionario?.nome ?? 'Funcionário não encontrado', matricula: periodo.funcionario?.matricula ?? null, - timeNome: periodo.time?.nome ?? null, + gestorNome: periodo.gestor?.nome ?? null, timeCor: periodo.time?.cor ?? null, status: periodo.status, dataInicio: periodo.dataInicio, @@ -1134,7 +1134,7 @@ const dadosTabela: string[][] = periodosSelecionados.map((periodo) => [ periodo.funcionarioNome, periodo.matricula ?? 'S/N', - periodo.timeNome ?? 'Sem time', + periodo.gestorNome ?? 'Sem gestor', periodo.anoReferencia.toString(), formatarData(periodo.dataInicio), formatarData(periodo.dataFim), @@ -1146,7 +1146,7 @@ if (dadosTabela.length > 0) { autoTable(doc, { startY: yPosition, - head: [['Funcionário', 'Matrícula', 'Time', 'Ano Ref.', 'Início', 'Fim', 'Dias', 'Status']], + head: [['Funcionário', 'Matrícula', 'Gestor', 'Ano Ref.', 'Início', 'Fim', 'Dias', 'Status']], body: dadosTabela, theme: 'striped', headStyles: { @@ -1159,7 +1159,7 @@ columnStyles: { 0: { cellWidth: 40, fontSize: 7 }, // Funcionário 1: { cellWidth: 20, fontSize: 7 }, // Matrícula - 2: { cellWidth: 25, fontSize: 7 }, // Time + 2: { cellWidth: 25, fontSize: 7 }, // Gestor 3: { cellWidth: 15, fontSize: 7 }, // Ano Ref. 4: { cellWidth: 22, fontSize: 7 }, // Início 5: { cellWidth: 22, fontSize: 7 }, // Fim @@ -1218,7 +1218,7 @@ const dados: Array> = periodosSelecionados.map((periodo) => ({ 'Funcionário': periodo.funcionarioNome, 'Matrícula': periodo.matricula ?? 'S/N', - 'Time': periodo.timeNome ?? 'Sem time', + 'Gestor': periodo.gestorNome ?? 'Sem gestor', 'Ano Ref.': periodo.anoReferencia, 'Data Início': formatarData(periodo.dataInicio), 'Data Fim': formatarData(periodo.dataFim), @@ -1495,7 +1495,7 @@ ${index + 1} ${periodo.funcionarioNome} ${periodo.matricula ?? 'S/N'} - ${periodo.timeNome ?? 'Sem time'} + ${periodo.gestorNome ?? 'Sem gestor'} ${periodo.anoReferencia} ${formatarData(periodo.dataInicio)} ${formatarData(periodo.dataFim)} @@ -1603,7 +1603,7 @@ # Funcionário Matrícula - Time + Gestor Ano Ref. Início Fim @@ -2688,10 +2688,18 @@
-
- {periodo.funcionario?.nome.substring(0, 2).toUpperCase()} +
+ {#if periodo.funcionario && 'fotoPerfilUrl' in periodo.funcionario && periodo.funcionario.fotoPerfilUrl} + {`Foto + {:else} + {periodo.funcionario?.nome?.substring(0, 2).toUpperCase() || '??'} + {/if}
diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index 0c16830..c071260 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -80,6 +80,15 @@ export const listarTodas = query({ todasFerias.map(async (ferias) => { const funcionario = await ctx.db.get(ferias.funcionarioId); + // Buscar usuário do funcionário para obter fotoPerfilUrl + let fotoPerfilUrl: string | null = null; + if (funcionario?.usuarioId) { + const usuario = await ctx.db.get(funcionario.usuarioId); + if (usuario?.fotoPerfil) { + fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); + } + } + // Buscar time do funcionário const membroTime = await ctx.db .query("timesMembros") @@ -114,7 +123,10 @@ export const listarTodas = query({ return { ...ferias, - funcionario, + funcionario: funcionario ? { + ...funcionario, + fotoPerfilUrl, + } : null, time, gestor, }; From 545e119367e994c327885d1244086a79e16fa6f1 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 29 Nov 2025 22:27:23 -0300 Subject: [PATCH 10/31] feat: add area chart for upcoming employee leave data, visualizing monthly vacation counts and enhancing dashboard insights --- .../recursos-humanos/ferias/+page.svelte | 143 ++++++++++++++++++ packages/backend/convex/ferias.ts | 18 +-- packages/backend/convex/schema.ts | 4 + 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte index 666feb8..dbe2863 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte @@ -8,6 +8,7 @@ import type { FunctionReturnType } from 'convex/server'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import BarChart3D from '$lib/components/ti/charts/BarChart3D.svelte'; + import AreaChart from '$lib/components/ti/charts/AreaChart.svelte'; import AlterarStatusFerias from '$lib/components/AlterarStatusFerias.svelte'; import FuncionarioNomeAutocomplete from '$lib/components/FuncionarioNomeAutocomplete.svelte'; import FuncionarioMatriculaAutocomplete from '$lib/components/FuncionarioMatriculaAutocomplete.svelte'; @@ -529,6 +530,108 @@ return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`; } + // Dados para gráfico de área - Funcionários de férias nos próximos 12 meses + const chartDataFuncionariosFerias = $derived(() => { + const hoje = new Date(); + hoje.setHours(0, 0, 0, 0); + + // Criar array com os próximos 12 meses + const meses: Array<{ mes: string; dataInicio: Date; dataFim: Date; quantidade: number }> = []; + + for (let i = 0; i < 12; i++) { + const dataInicioMes = new Date(hoje.getFullYear(), hoje.getMonth() + i, 1); + const dataFimMes = new Date(hoje.getFullYear(), hoje.getMonth() + i + 1, 0); + dataFimMes.setHours(23, 59, 59, 999); + + const mesLabel = format(dataInicioMes, 'MMM/yyyy', { locale: ptBR }); + meses.push({ + mes: mesLabel, + dataInicio: dataInicioMes, + dataFim: dataFimMes, + quantidade: 0 + }); + } + + // Filtrar apenas solicitações aprovadas que estão ou estarão em férias + const solicitacoesAprovadas = ultimasSolicitacoesValidas.filter( + (s) => + s.status === 'aprovado' || + s.status === 'data_ajustada_aprovada' || + s.status === 'EmFérias' + ); + + // Calcular quantos funcionários estarão de férias em cada mês + meses.forEach((mesInfo) => { + const funcionariosEmFerias = new Set(); + + solicitacoesAprovadas.forEach((solicitacao) => { + if (!solicitacao.funcionarioId) return; + + const dataInicio = new Date(solicitacao.dataInicio); + const dataFim = new Date(solicitacao.dataFim); + dataInicio.setHours(0, 0, 0, 0); + dataFim.setHours(23, 59, 59, 999); + + // Verificar se o período de férias se sobrepõe com o mês + if ( + (dataInicio <= mesInfo.dataFim && dataFim >= mesInfo.dataInicio) + ) { + funcionariosEmFerias.add(String(solicitacao.funcionarioId)); + } + }); + + mesInfo.quantidade = funcionariosEmFerias.size; + }); + + // Cores harmoniosas com o tema (gradiente de azul primary para accent) + const corBase = '#3b82f6'; // Azul primary + const corAccent = '#8b5cf6'; // Roxo accent + + // Criar gradiente de cores harmonioso + const cores = meses.map((_, index) => { + const ratio = meses.length > 1 ? index / (meses.length - 1) : 0; + const r1 = parseInt(corBase.slice(1, 3), 16); + const g1 = parseInt(corBase.slice(3, 5), 16); + const b1 = parseInt(corBase.slice(5, 7), 16); + const r2 = parseInt(corAccent.slice(1, 3), 16); + const g2 = parseInt(corAccent.slice(3, 5), 16); + const b2 = parseInt(corAccent.slice(5, 7), 16); + + const r = Math.round(r1 + (r2 - r1) * ratio); + const g = Math.round(g1 + (g2 - g1) * ratio); + const b = Math.round(b1 + (b2 - b1) * ratio); + + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + }); + + // Cor principal para a borda (mais vibrante) + const corBorda = corBase; + + return { + labels: meses.map((m) => m.mes), + datasets: [ + { + label: 'Funcionários de Férias', + data: meses.map((m) => m.quantidade), + backgroundColor: cores.map((cor) => `${cor}60`), // 37.5% de opacidade para preenchimento + borderColor: corBorda, + borderWidth: 3, + pointBackgroundColor: cores, + pointBorderColor: '#ffffff', + pointBorderWidth: 2, + pointRadius: 5, + pointHoverRadius: 7, + pointHoverBackgroundColor: corBorda, + pointHoverBorderColor: '#ffffff', + pointHoverBorderWidth: 3, + fill: true, + tension: 0.4, + spanGaps: false + } + ] + }; + }); + const coresCalendario = [ '#2563eb', '#16a34a', @@ -2342,6 +2445,46 @@
{:else}
+ +
+
+
+
+ + + +
+
+

+ Funcionários de Férias - Próximos 12 Meses +

+

+ Quantitativo de funcionários que estarão de férias mês a mês +

+
+
+
+ {#if chartDataFuncionariosFerias.labels.length === 0} +
+

Sem dados registrados até o momento.

+
+ {:else} + + {/if} +
+
+
{/if} {:else if abaAtiva === 'solicitacoes'} diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index c071260..747fa31 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -82,8 +82,11 @@ export const listarTodas = query({ // Buscar usuário do funcionário para obter fotoPerfilUrl let fotoPerfilUrl: string | null = null; - if (funcionario?.usuarioId) { - const usuario = await ctx.db.get(funcionario.usuarioId); + if (funcionario) { + const usuario = await ctx.db + .query("usuarios") + .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id)) + .first(); if (usuario?.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } @@ -98,19 +101,16 @@ export const listarTodas = query({ .filter((q) => q.eq(q.field("ativo"), true)) .first(); - let time = null; - let gestor = null; + let time: Doc<"times"> | null = null; + let gestor: { _id: Id<"usuarios">; nome: string } | null = null; if (membroTime) { time = await ctx.db.get(membroTime.timeId); // Buscar gestor do time if (time?.gestorId) { const gestorUsuario = await ctx.db.get(time.gestorId); - if (gestorUsuario) { + if (gestorUsuario?.funcionarioId) { // Buscar funcionário do gestor para obter o nome - const gestorFuncionario = await ctx.db - .query("funcionarios") - .withIndex("by_usuario", (q) => q.eq("usuarioId", time.gestorId)) - .first(); + const gestorFuncionario = await ctx.db.get(gestorUsuario.funcionarioId); if (gestorFuncionario) { gestor = { _id: gestorUsuario._id, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index a02147e..21afec4 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -871,6 +871,10 @@ export default defineSchema({ configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor configuradoPor: v.id("usuarios"), // Usuário que configurou atualizadoEm: v.number(), // Timestamp de atualização + jitsiConfigPath: v.optional(v.string()), // Caminho da configuração do Jitsi no servidor (ex: "~/.jitsi-meet-cfg") + sshUsername: v.optional(v.string()), // Usuário SSH para acesso ao servidor + sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada) + sshPort: v.optional(v.number()), // Porta SSH (padrão: 22) }).index("by_ativo", ["ativo"]), // Fila de Emails From 298326e264709affb9755733e2efaf8b0415a5a5 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 29 Nov 2025 23:25:14 -0300 Subject: [PATCH 11/31] fix: enhance data handling in vacation dashboard by adding array checks and improving chart data structure for better stability and performance --- .../recursos-humanos/ferias/+page.svelte | 113 +++++++++++------- 1 file changed, 72 insertions(+), 41 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte index dbe2863..40790b0 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { onMount, tick } from 'svelte'; - import { SvelteDate, SvelteMap } from 'svelte/reactivity'; + import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; import type { FunctionReturnType } from 'convex/server'; @@ -110,7 +110,7 @@ // Usar último valor válido ou array vazio const solicitacoes = $derived( - todasSolicitacoesQuery?.data ?? ultimasSolicitacoesValidas + (todasSolicitacoesQuery?.data ?? ultimasSolicitacoesValidas) || [] ); // Filtros Dashboard @@ -153,6 +153,9 @@ periodoFim: string; } ): TodasSolicitacoes { + if (!Array.isArray(lista)) { + return []; + } return lista.filter((periodo) => { if (filtros.status !== 'todos' && periodo.status !== filtros.status) { return false; @@ -271,14 +274,14 @@ }); const solicitacoesAprovadas = $derived( - solicitacoesFiltradas.filter( + (Array.isArray(solicitacoesFiltradas) ? solicitacoesFiltradas : []).filter( (p) => p.status === 'aprovado' || p.status === 'data_ajustada_aprovada' || p.status === 'EmFérias' ) ); const periodosDetalhados = $derived>( - solicitacoesAprovadas + (Array.isArray(solicitacoesAprovadas) ? solicitacoesAprovadas : []) .map((periodo) => ({ feriasId: periodo._id, funcionarioId: periodo.funcionarioId, @@ -303,6 +306,10 @@ (() => { const agregados = new SvelteMap(); + if (!Array.isArray(periodosDetalhados)) { + return []; + } + for (const periodo of periodosDetalhados) { const inicio = new SvelteDate(`${periodo.dataInicio}T00:00:00`); const chave = `${inicio.getFullYear()}-${String(inicio.getMonth() + 1).padStart(2, '0')}`; @@ -531,19 +538,41 @@ } // Dados para gráfico de área - Funcionários de férias nos próximos 12 meses - const chartDataFuncionariosFerias = $derived(() => { - const hoje = new Date(); + type ChartData = { + labels: string[]; + datasets: Array<{ + label: string; + data: number[]; + backgroundColor: string[]; + borderColor: string; + borderWidth: number; + pointBackgroundColor: string[]; + pointBorderColor: string; + pointBorderWidth: number; + pointRadius: number; + pointHoverRadius: number; + pointHoverBackgroundColor: string; + pointHoverBorderColor: string; + pointHoverBorderWidth: number; + fill: boolean; + tension: number; + spanGaps: boolean; + }>; + }; + const chartDataFuncionariosFerias: ChartData = $derived.by(() => { + // Sempre criar os 12 meses, mesmo sem dados + const hoje = new SvelteDate(); hoje.setHours(0, 0, 0, 0); // Criar array com os próximos 12 meses - const meses: Array<{ mes: string; dataInicio: Date; dataFim: Date; quantidade: number }> = []; + const meses: Array<{ mes: string; dataInicio: SvelteDate; dataFim: SvelteDate; quantidade: number }> = []; for (let i = 0; i < 12; i++) { - const dataInicioMes = new Date(hoje.getFullYear(), hoje.getMonth() + i, 1); - const dataFimMes = new Date(hoje.getFullYear(), hoje.getMonth() + i + 1, 0); + const dataInicioMes = new SvelteDate(hoje.getFullYear(), hoje.getMonth() + i, 1); + const dataFimMes = new SvelteDate(hoje.getFullYear(), hoje.getMonth() + i + 1, 0); dataFimMes.setHours(23, 59, 59, 999); - const mesLabel = format(dataInicioMes, 'MMM/yyyy', { locale: ptBR }); + const mesLabel = format(new Date(dataInicioMes.getTime()), 'MMM/yyyy', { locale: ptBR }); meses.push({ mes: mesLabel, dataInicio: dataInicioMes, @@ -552,36 +581,38 @@ }); } - // Filtrar apenas solicitações aprovadas que estão ou estarão em férias - const solicitacoesAprovadas = ultimasSolicitacoesValidas.filter( - (s) => - s.status === 'aprovado' || - s.status === 'data_ajustada_aprovada' || - s.status === 'EmFérias' - ); + // Verificação de segurança e filtrar apenas solicitações aprovadas + if (ultimasSolicitacoesValidas && Array.isArray(ultimasSolicitacoesValidas)) { + const solicitacoesAprovadas = ultimasSolicitacoesValidas.filter( + (s) => + s.status === 'aprovado' || + s.status === 'data_ajustada_aprovada' || + s.status === 'EmFérias' + ); - // Calcular quantos funcionários estarão de férias em cada mês - meses.forEach((mesInfo) => { - const funcionariosEmFerias = new Set(); - - solicitacoesAprovadas.forEach((solicitacao) => { - if (!solicitacao.funcionarioId) return; + // Calcular quantos funcionários estarão de férias em cada mês + meses.forEach((mesInfo) => { + const funcionariosEmFerias = new SvelteSet(); - const dataInicio = new Date(solicitacao.dataInicio); - const dataFim = new Date(solicitacao.dataFim); - dataInicio.setHours(0, 0, 0, 0); - dataFim.setHours(23, 59, 59, 999); + solicitacoesAprovadas.forEach((solicitacao) => { + if (!solicitacao.funcionarioId) return; + + const dataInicio = new SvelteDate(solicitacao.dataInicio); + const dataFim = new SvelteDate(solicitacao.dataFim); + dataInicio.setHours(0, 0, 0, 0); + dataFim.setHours(23, 59, 59, 999); + + // Verificar se o período de férias se sobrepõe com o mês + if ( + (dataInicio.getTime() <= mesInfo.dataFim.getTime() && dataFim.getTime() >= mesInfo.dataInicio.getTime()) + ) { + funcionariosEmFerias.add(String(solicitacao.funcionarioId)); + } + }); - // Verificar se o período de férias se sobrepõe com o mês - if ( - (dataInicio <= mesInfo.dataFim && dataFim >= mesInfo.dataInicio) - ) { - funcionariosEmFerias.add(String(solicitacao.funcionarioId)); - } + mesInfo.quantidade = funcionariosEmFerias.size; }); - - mesInfo.quantidade = funcionariosEmFerias.size; - }); + } // Cores harmoniosas com o tema (gradiente de azul primary para accent) const corBase = '#3b82f6'; // Azul primary @@ -2475,12 +2506,12 @@
- {#if chartDataFuncionariosFerias.labels.length === 0} -
-

Sem dados registrados até o momento.

-
- {:else} + {#if chartDataFuncionariosFerias.labels && chartDataFuncionariosFerias.labels.length > 0} + {:else} +
+

Carregando dados do gráfico...

+
{/if}
From b85021d924d4f2eaaacd64ed829c6194a0f817ac Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 00:30:38 -0300 Subject: [PATCH 12/31] feat: implement area charts for total days by type and monthly trends in the employee leave dashboard, enhancing data visualization and user insights --- .../atestados-licencas/+page.svelte | 361 +++++------------- 1 file changed, 97 insertions(+), 264 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte index 96f290a..d9e4470 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte @@ -10,6 +10,7 @@ import FileUpload from '$lib/components/FileUpload.svelte'; import ErrorModal from '$lib/components/ErrorModal.svelte'; import CalendarioAfastamentos from '$lib/components/CalendarioAfastamentos.svelte'; + import AreaChart from '$lib/components/ti/charts/AreaChart.svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; @@ -141,6 +142,86 @@ }; } + // Dados para gráfico de área - Total de Dias por Tipo (Layerchart) + const chartDataTotalDiasPorTipo = $derived.by(() => { + if (!graficosQuery?.data?.totalDiasPorTipo) { + return { + labels: [], + datasets: [] + }; + } + + const dados = graficosQuery.data.totalDiasPorTipo; + const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']; + + return { + labels: dados.map((d) => d.tipo), + datasets: [ + { + label: 'Total de Dias', + data: dados.map((d) => d.dias), + backgroundColor: dados.map((_, i) => `${cores[i % cores.length]}80`), + borderColor: dados.map((_, i) => cores[i % cores.length]), + borderWidth: 3, + pointBackgroundColor: dados.map((_, i) => cores[i % cores.length]), + pointBorderColor: '#ffffff', + pointBorderWidth: 2, + pointRadius: 6, + pointHoverRadius: 8, + pointHoverBackgroundColor: dados.map((_, i) => cores[i % cores.length]), + pointHoverBorderColor: '#ffffff', + pointHoverBorderWidth: 3, + fill: true, + tension: 0.4, + spanGaps: false + } + ] + }; + }); + + // Dados para gráfico de área - Tendências Mensais (Layerchart empilhado) + const chartDataTendenciasMensais = $derived.by(() => { + if (!graficosQuery?.data?.tendenciasMensais) { + return { + labels: [], + datasets: [] + }; + } + + const tendencias = graficosQuery.data.tendenciasMensais; + const tipos = [ + 'atestado_medico', + 'declaracao_comparecimento', + 'maternidade', + 'paternidade', + 'ferias' + ]; + const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']; + const nomes = ['Atestado Médico', 'Declaração', 'Maternidade', 'Paternidade', 'Férias']; + + return { + labels: tendencias.map((t) => t.mes), + datasets: tipos.map((tipo, idx) => ({ + label: nomes[idx], + data: tendencias.map((t) => (t[tipo as keyof typeof t] as number) || 0), + backgroundColor: `${cores[idx]}60`, + borderColor: cores[idx], + borderWidth: 2, + pointBackgroundColor: cores[idx], + pointBorderColor: '#ffffff', + pointBorderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointHoverBackgroundColor: cores[idx], + pointHoverBorderColor: '#ffffff', + pointHoverBorderWidth: 3, + fill: true, + tension: 0.4, + spanGaps: false + })) + }; + }); + // Salvar Atestado Médico async function salvarAtestadoMedico() { if ( @@ -1643,282 +1724,34 @@ {#if graficosQuery?.data} - {@const dados = graficosQuery.data.totalDiasPorTipo} - {@const maxDias = Math.max(...dados.map((d) => d.dias), 1)} - {@const chartWidth = 800} - {@const chartHeight = 350} - {@const padding = { top: 20, right: 40, bottom: 80, left: 70 }} - {@const barWidth = (chartWidth - padding.left - padding.right) / dados.length - 10} - {@const innerHeight = chartHeight - padding.top - padding.bottom} - {@const tendencias = graficosQuery.data.tendenciasMensais} - {@const tipos = [ - 'atestado_medico', - 'declaracao_comparecimento', - 'maternidade', - 'paternidade', - 'ferias' - ]} - {@const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']} - {@const nomes = ['Atestado Médico', 'Declaração', 'Maternidade', 'Paternidade', 'Férias']} - {@const maxValor = Math.max( - ...tendencias.flatMap((t) => tipos.map((tipo) => t[tipo as keyof typeof t] as number)), - 1 - )} - {@const chartWidth2 = 900} - {@const chartHeight2 = 400} - {@const padding2 = { top: 20, right: 40, bottom: 80, left: 70 }} - {@const innerWidth = chartWidth2 - padding2.left - padding2.right} - {@const innerHeight2 = chartHeight2 - padding2.top - padding2.bottom} - +

Total de Dias por Tipo

- - - {#each [0, 1, 2, 3, 4, 5] as t} - {@const val = Math.round((maxDias / 5) * t)} - {@const y = chartHeight - padding.bottom - (val / maxDias) * innerHeight} - - - {val} - - {/each} - - - - - - - {#each dados as item, i} - {@const x = padding.left + i * (barWidth + 10) + 5} - {@const height = (item.dias / maxDias) * innerHeight} - {@const y = chartHeight - padding.bottom - height} - {@const colors = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']} - - - - - - - - - - - - - - {#if item.dias > 0} - - {item.dias} - - {/if} - - - -
- - {item.tipo} - -
-
- {/each} -
+ {#if chartDataTotalDiasPorTipo.labels.length === 0} +
+

Sem dados registrados até o momento.

+
+ {:else} + + {/if}
- +

Tendências Mensais (Últimos 6 Meses)

- - - {#each [0, 1, 2, 3, 4, 5] as t} - {@const val = Math.round((maxValor / 5) * t)} - {@const y = chartHeight2 - padding2.bottom - (val / maxValor) * innerHeight2} - - - {val} - - {/each} - - - - - - - {#each tipos as tipo, tipoIdx} - {@const cor = cores[tipoIdx]} - - - - - - - - - - {@const pontos = tendencias.map((t, i) => { - const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth; - const valor = t[tipo as keyof typeof t] as number; - const y = chartHeight2 - padding2.bottom - (valor / maxValor) * innerHeight2; - return { x, y, valor }; - })} - - - {#if pontos.length > 0} - {@const pathArea = - `M ${pontos[0].x} ${chartHeight2 - padding2.bottom} ` + - pontos.map((p) => `L ${p.x} ${p.y}`).join(' ') + - ` L ${pontos[pontos.length - 1].x} ${chartHeight2 - padding2.bottom} Z`} - - {/if} - - - {#if pontos.length > 1} - `${p.x},${p.y}`).join(' ')} - fill="none" - stroke={cor} - stroke-width="3" - stroke-linecap="round" - stroke-linejoin="round" - /> - {/if} - - - {#each pontos as ponto, pontoIdx} - - - {nomes[tipoIdx]}: {ponto.valor} dias em {tendencias[pontoIdx]?.mes || - ''} - {/each} - {/each} - - - {#each tendencias as t, i} - {@const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth} - -
- - {t.mes} - -
-
- {/each} -
- - -
- {#each tipos as tipo, idx} -
-
- {nomes[idx]} -
- {/each} -
+ {#if chartDataTendenciasMensais.labels.length === 0} +
+

Sem dados registrados até o momento.

+
+ {:else} + + {/if}
From 39c948aa6b181d7359f53c0cb68f0345c6279e25 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 00:35:20 -0300 Subject: [PATCH 13/31] refactor: reorganize user profile display in Sidebar component, moving notification bell and user details for improved layout and accessibility --- apps/web/src/lib/components/Sidebar.svelte | 61 +++++++++++----------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 22a158e..c381611 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -213,15 +213,6 @@
{#if currentUser.data} - -
- -
- - - -
+ + {#if avatarUrlDoUsuario()} + {currentUser.data?.nome + {:else} + + + {/if} - - {#if avatarUrlDoUsuario()} - {currentUser.data?.nome - {:else} - - - {/if} + +
- +
+ + + + +
+ +
{:else}
- -
- - - - + From e35846103e590f09f277d4a77dae8aee8d7455f2 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 00:43:17 -0300 Subject: [PATCH 15/31] refactor: remove unused card hover styles from app.css and update card class in dashboard to simplify styling --- apps/web/src/app.css | 54 ------------------- .../src/routes/(dashboard)/ti/+page.svelte | 2 +- 2 files changed, 1 insertion(+), 55 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 5af36f7..865aaf1 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -21,60 +21,6 @@ @apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-error bg-base-100 hover:bg-error/60 active:bg-error text-error hover:text-white active:text-white transition-colors; } -:where(.card, .card-hover) { - position: relative; - overflow: hidden; - transform: translateY(0); - transition: transform 220ms ease, box-shadow 220ms ease; -} - -:where(.card, .card-hover)::before { - content: ""; - position: absolute; - inset: -2px; - border-radius: 1.15rem; - box-shadow: - 0 0 0 1px hsl(var(--bc) / 0.04), - 0 14px 32px -22px hsl(var(--bc) / 0.45), - 0 6px 18px -16px hsl(var(--p) / 0.35); - opacity: 0.55; - transition: opacity 220ms ease, transform 220ms ease; - pointer-events: none; - z-index: 0; -} - -:where(.card, .card-hover)::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 1rem; - background: linear-gradient(135deg, hsl(var(--p) / 0.12), hsl(var(--s) / 0.12)); - opacity: 0; - transform: scale(0.96); - transition: opacity 220ms ease, transform 220ms ease; - pointer-events: none; - z-index: 1; -} - -:where(.card, .card-hover):hover { - transform: translateY(-6px); - box-shadow: 0 20px 45px -20px hsl(var(--bc) / 0.35); -} - -:where(.card, .card-hover):hover::before { - opacity: 0.9; - transform: scale(1); -} - -:where(.card, .card-hover):hover::after { - opacity: 1; - transform: scale(1); -} - -:where(.card, .card-hover) > * { - position: relative; - z-index: 2; -} /* Tema Aqua (padrão roxo/azul) - customizado para garantir funcionamento */ html[data-theme="aqua"], diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index faa37e5..1a5f058 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -425,7 +425,7 @@
{#each featureCards as card (card.title)}
Date: Sun, 30 Nov 2025 08:12:46 -0300 Subject: [PATCH 16/31] feat: enhance login functionality by adding IP geolocation tracking and advanced filtering options in the audit page, improving user insights and data accuracy --- apps/web/src/lib/components/Sidebar.svelte | 70 ++- .../(dashboard)/ti/auditoria/+page.svelte | 460 ++++++++++++++++-- packages/backend/convex/logsLogin.ts | 108 ++++ packages/backend/convex/schema.ts | 10 + 4 files changed, 610 insertions(+), 38 deletions(-) diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index c381611..55480a2 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -4,7 +4,7 @@ import logo from '$lib/assets/logo_governo_PE.png'; import type { Snippet } from 'svelte'; import { loginModalStore } from '$lib/stores/loginModal.svelte'; - import { useQuery } from 'convex-svelte'; + import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; import NotificationBell from '$lib/components/chat/NotificationBell.svelte'; import ChatWidget from '$lib/components/chat/ChatWidget.svelte'; @@ -14,11 +14,26 @@ import { authClient } from '$lib/auth'; import { resolve } from '$app/paths'; + // Função para obter IP público (similar ao sistema de ponto) + async function obterIPPublico(): Promise { + try { + const response = await fetch('https://api.ipify.org?format=json'); + if (response.ok) { + const data = (await response.json()) as { ip: string }; + return data.ip; + } + } catch (error) { + console.warn('Erro ao obter IP público:', error); + } + return undefined; + } + let { children }: { children: Snippet } = $props(); const currentPath = $derived(page.url.pathname); const currentUser = useQuery(api.auth.getCurrentUser, {}); + const convexClient = useConvexClient(); // Função para obter a URL do avatar/foto do usuário const avatarUrlDoUsuario = $derived(() => { @@ -122,18 +137,67 @@ erroLogin = ''; carregandoLogin = true; - // const browserInfo = await getBrowserInfo(); + // Obter IP público e userAgent antes do login + const [ipPublico, userAgent] = await Promise.all([ + obterIPPublico().catch(() => undefined), + Promise.resolve(typeof navigator !== 'undefined' ? navigator.userAgent : undefined) + ]); const result = await authClient.signIn.email( { email: matricula.trim(), password: senha }, { - onError: (ctx) => { + onError: async (ctx) => { + // Registrar tentativa de login falha + try { + await convexClient.mutation(api.logsLogin.registrarTentativaLogin, { + matriculaOuEmail: matricula.trim(), + sucesso: false, + motivoFalha: ctx.error?.message || 'Erro desconhecido', + userAgent: userAgent, + ipAddress: ipPublico, + }); + } catch (err) { + console.error('Erro ao registrar tentativa de login falha:', err); + } alert(ctx.error.message); } } ); if (result.data) { + // Registrar tentativa de login bem-sucedida + // Fazer de forma assíncrona para não bloquear o login + (async () => { + try { + // Aguardar um pouco para o usuário ser sincronizado no Convex + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Buscar o usuário no Convex usando getCurrentUser + const usuario = await convexClient.query(api.auth.getCurrentUser, {}); + + if (usuario && usuario._id) { + await convexClient.mutation(api.logsLogin.registrarTentativaLogin, { + usuarioId: usuario._id, + matriculaOuEmail: matricula.trim(), + sucesso: true, + userAgent: userAgent, + ipAddress: ipPublico, + }); + } else { + // Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois) + await convexClient.mutation(api.logsLogin.registrarTentativaLogin, { + matriculaOuEmail: matricula.trim(), + sucesso: true, + userAgent: userAgent, + ipAddress: ipPublico, + }); + } + } catch (err) { + console.error('Erro ao registrar tentativa de login:', err); + // Não bloquear o login se houver erro ao registrar + } + })(); + closeLoginModal(); goto(resolve('/')); } else { diff --git a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte index 85e4d2e..6f345dd 100644 --- a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte @@ -1,26 +1,149 @@
@@ -163,13 +447,38 @@
- - +
+ + + {#if mostrarFiltros} +
+
+
+ + +
+
+ + +
+
+ + +
+ {#if abaAtiva === "logins"} +
+ + +
+
+ + +
+ {/if} +
+ +
+
+ {/if}
@@ -191,17 +569,17 @@ Atividades Recentes - {#if atividades?.data} -
{atividades.data.length} registro{atividades.data.length !== 1 ? 's' : ''}
+ {#if atividades} +
{atividades.length} registro{atividades.length !== 1 ? 's' : ''}
{/if}
- {#if !atividades?.data} + {#if atividadesRaw === undefined}

Carregando atividades...

- {:else if atividades.data.length === 0} + {:else if atividades.length === 0}
@@ -222,7 +600,7 @@ - {#each atividades.data as atividade} + {#each atividades as atividade}
@@ -277,17 +655,17 @@ Histórico de Logins - {#if logins?.data} -
{logins.data.length} registro{logins.data.length !== 1 ? 's' : ''}
+ {#if logins} +
{logins.length} registro{logins.length !== 1 ? 's' : ''}
{/if}
- {#if !logins?.data} + {#if loginsRaw === undefined}

Carregando logins...

- {:else if logins.data.length === 0} + {:else if logins.length === 0}
@@ -304,13 +682,14 @@ Usuário/Email Status IP + Localização Dispositivo Navegador Sistema - {#each logins.data as login} + {#each logins as login}
@@ -353,6 +732,17 @@ {login.ipAddress || "-"} + +
+ + + + + + {formatarLocalizacao(login)} + +
+
{login.device || "-"}
diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts index 4969c56..3911716 100644 --- a/packages/backend/convex/logsLogin.ts +++ b/packages/backend/convex/logsLogin.ts @@ -2,6 +2,61 @@ import { v } from "convex/values"; import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; +/** + * Obtém geolocalização aproximada por IP usando serviço externo + * Similar ao sistema de ponto + */ +async function obterGeoPorIP(ipAddress: string): Promise<{ + latitude: number; + longitude: number; + cidade?: string; + estado?: string; + pais?: string; + endereco?: string; +} | null> { + try { + // Usar ipapi.co (gratuito, sem chave para uso limitado) + const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, { + headers: { + 'User-Agent': 'SGSE-App/1.0' + } + }); + + if (response.ok) { + const data = (await response.json()) as { + latitude?: number; + longitude?: number; + city?: string; + region?: string; + country_name?: string; + error?: boolean; + }; + + if (!data.error && data.latitude && data.longitude) { + // Montar endereço completo + const partesEndereco: string[] = []; + if (data.city) partesEndereco.push(data.city); + if (data.region) partesEndereco.push(data.region); + if (data.country_name) partesEndereco.push(data.country_name); + const endereco = partesEndereco.length > 0 ? partesEndereco.join(', ') : undefined; + + return { + latitude: data.latitude, + longitude: data.longitude, + cidade: data.city, + estado: data.region, + pais: data.country_name, + endereco + }; + } + } + } catch (error) { + console.warn('Erro ao obter geolocalização por IP:', error); + } + + return null; +} + /** * Helper para registrar tentativas de login */ @@ -52,6 +107,26 @@ export async function registrarLogin( // Validar e sanitizar IP antes de salvar const ipAddressValidado = validarIP(dados.ipAddress); + + // Obter geolocalização por IP se disponível (de forma assíncrona para não bloquear) + let geolocalizacao: { + latitude?: number; + longitude?: number; + cidade?: string; + estado?: string; + pais?: string; + endereco?: string; + } | null = null; + + if (ipAddressValidado) { + // Obter geolocalização por IP (não bloquear se falhar) + try { + geolocalizacao = await obterGeoPorIP(ipAddressValidado); + } catch (error) { + console.warn('Erro ao obter geolocalização por IP:', error); + // Continuar sem localização se houver erro + } + } await ctx.db.insert("logsLogin", { usuarioId: dados.usuarioId, @@ -63,6 +138,13 @@ export async function registrarLogin( device, browser, sistema, + // Informações de Localização + latitude: geolocalizacao?.latitude, + longitude: geolocalizacao?.longitude, + cidade: geolocalizacao?.cidade, + estado: geolocalizacao?.estado, + pais: geolocalizacao?.pais, + endereco: geolocalizacao?.endereco, timestamp: Date.now(), }); @@ -280,6 +362,32 @@ function extrairSistema(userAgent: string): string { return "Desconhecido"; } +/** + * Mutation pública para registrar tentativa de login + * Pode ser chamada do frontend após login bem-sucedido ou falho + */ +export const registrarTentativaLogin = mutation({ + args: { + usuarioId: v.optional(v.id("usuarios")), + matriculaOuEmail: v.string(), + sucesso: v.boolean(), + motivoFalha: v.optional(v.string()), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await registrarLogin(ctx, { + usuarioId: args.usuarioId, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: args.sucesso, + motivoFalha: args.motivoFalha, + ipAddress: args.ipAddress, + userAgent: args.userAgent, + }); + return { success: true }; + }, +}); + /** * Lista histórico de logins de um usuário */ diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 21afec4..cfef17e 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -779,11 +779,21 @@ export default defineSchema({ matriculaOuEmail: v.string(), // tentativa de login sucesso: v.boolean(), motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente" + // Informações de Rede ipAddress: v.optional(v.string()), + ipPublico: v.optional(v.string()), + ipLocal: v.optional(v.string()), userAgent: v.optional(v.string()), device: v.optional(v.string()), browser: v.optional(v.string()), sistema: v.optional(v.string()), + // Informações de Localização + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), + endereco: v.optional(v.string()), + cidade: v.optional(v.string()), + estado: v.optional(v.string()), + pais: v.optional(v.string()), timestamp: v.number(), }) .index("by_usuario", ["usuarioId"]) From f1c2ae0e6b0125d7e3ed6b7bc3ce12e4e5a7ab4b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 08:42:21 -0300 Subject: [PATCH 17/31] feat: enhance audit page by adding user information retrieval and improving CSV export format, providing better insights and clarity in reports --- .../(dashboard)/ti/auditoria/+page.svelte | 150 +++++++++++++----- packages/backend/convex/logsLogin.ts | 24 ++- 2 files changed, 133 insertions(+), 41 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte index 6f345dd..8a42d70 100644 --- a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte @@ -7,6 +7,7 @@ import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; import Papa from "papaparse"; + import logoGovPE from "$lib/assets/logo_governo_PE.png"; let abaAtiva = $state<"atividades" | "logins">("atividades"); let limite = $state(50); @@ -96,9 +97,12 @@ } if (filtroUsuario) { const usuarioLower = filtroUsuario.toLowerCase(); - filtrados = filtrados.filter(l => - l.matriculaOuEmail.toLowerCase().includes(usuarioLower) - ); + filtrados = filtrados.filter(l => { + const nomeMatch = l.usuarioNome?.toLowerCase().includes(usuarioLower); + const emailMatch = l.usuarioEmail?.toLowerCase().includes(usuarioLower); + const matriculaMatch = l.matriculaOuEmail?.toLowerCase().includes(usuarioLower); + return nomeMatch || emailMatch || matriculaMatch; + }); } if (filtroStatus !== "todos") { filtrados = filtrados.filter(l => @@ -125,21 +129,27 @@ }); } - function formatarLocalizacao(login: { cidade?: string; estado?: string; pais?: string; endereco?: string } | undefined): string { - if (!login) return "-"; + function formatarLocalizacao(login: any): string { + if (!login || typeof login !== 'object') return "-"; const partes: string[] = []; - if (login.cidade) partes.push(login.cidade); - if (login.estado) partes.push(login.estado); - if (login.pais) partes.push(login.pais); + if (login.cidade && typeof login.cidade === 'string' && login.cidade.trim()) { + partes.push(login.cidade.trim()); + } + if (login.estado && typeof login.estado === 'string' && login.estado.trim()) { + partes.push(login.estado.trim()); + } + if (login.pais && typeof login.pais === 'string' && login.pais.trim()) { + partes.push(login.pais.trim()); + } if (partes.length > 0) { return partes.join(", "); } // Se não tiver cidade/estado/pais, mas tiver endereco, mostrar endereco - if (login.endereco) { - return login.endereco; + if (login.endereco && typeof login.endereco === 'string' && login.endereco.trim()) { + return login.endereco.trim(); } return "-"; @@ -198,17 +208,27 @@ "Detalhes": atividade.detalhes || "-" })); } else { - csvData = dadosParaExportar.map((login: any) => ({ - "Data/Hora": formatarData(login.timestamp), - "Usuário/Email": login.matriculaOuEmail, - "Status": login.sucesso ? "Sucesso" : "Falhou", - "Motivo Falha": login.motivoFalha || "-", - "IP": login.ipAddress || "-", - "Localização": formatarLocalizacao(login), - "Dispositivo": login.device || "-", - "Navegador": login.browser || "-", - "Sistema": login.sistema || "-" - })); + csvData = dadosParaExportar.map((login: any) => { + const usuarioInfo = []; + if (login.usuarioNome) usuarioInfo.push(login.usuarioNome); + if (login.usuarioEmail) usuarioInfo.push(login.usuarioEmail); + if (!login.usuarioNome && !login.usuarioEmail && login.matriculaOuEmail) { + usuarioInfo.push(login.matriculaOuEmail); + } + + return { + "Data/Hora": formatarData(login.timestamp), + "Usuário": login.usuarioNome || "-", + "Email": login.usuarioEmail || login.matriculaOuEmail || "-", + "Status": login.sucesso ? "Sucesso" : "Falhou", + "Motivo Falha": login.motivoFalha || "-", + "IP": login.ipAddress || "-", + "Localização": formatarLocalizacao(login), + "Dispositivo": login.device || "-", + "Navegador": login.browser || "-", + "Sistema": login.sistema || "-" + }; + }); } const csv = Papa.unparse(csvData); @@ -245,25 +265,49 @@ const doc = new jsPDF(); + // Adicionar logo no canto superior esquerdo + let yPos = 20; + try { + const logoImg = await new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + setTimeout(() => reject(new Error('Timeout loading logo')), 3000); + img.src = logoGovPE; + }); + + const logoWidth = 25; + const aspectRatio = logoImg.height / logoImg.width; + const logoHeight = logoWidth * aspectRatio; + + doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight); + yPos = 10 + logoHeight + 10; + } catch (err) { + console.warn('Erro ao carregar logo:', err); + yPos = 20; + } + // Título doc.setFontSize(20); doc.setTextColor(102, 126, 234); const titulo = abaAtiva === "atividades" ? "Relatório de Atividades do Sistema" : "Relatório de Histórico de Logins"; - doc.text(titulo, 14, 20); + doc.text(titulo, 14, yPos); // Informações gerais doc.setFontSize(12); doc.setTextColor(0, 0, 0); + yPos += 8; doc.text( `Gerado em: ${format(new Date(), "dd/MM/yyyy HH:mm", { locale: ptBR })}`, 14, - 30 + yPos ); - doc.text(`Total de registros: ${dadosParaExportar.length}`, 14, 36); - - let yPos = 50; + yPos += 6; + doc.text(`Total de registros: ${dadosParaExportar.length}`, 14, yPos); + yPos += 10; if (abaAtiva === "atividades") { // Tabela de atividades @@ -285,15 +329,25 @@ }); } else { // Tabela de logins - const loginsData = dadosParaExportar.map((login: any) => [ - formatarData(login.timestamp), - login.matriculaOuEmail, - login.sucesso ? "Sucesso" : "Falhou", - login.ipAddress || "-", - formatarLocalizacao(login).substring(0, 30), - login.device || "-", - login.browser || "-" - ]); + const loginsData = dadosParaExportar.map((login: any) => { + const usuarioInfo = []; + if (login.usuarioNome) usuarioInfo.push(login.usuarioNome); + if (login.usuarioEmail) usuarioInfo.push(login.usuarioEmail); + if (!login.usuarioNome && !login.usuarioEmail && login.matriculaOuEmail) { + usuarioInfo.push(login.matriculaOuEmail); + } + const usuarioStr = usuarioInfo.length > 0 ? usuarioInfo.join(" / ") : "-"; + + return [ + formatarData(login.timestamp), + usuarioStr, + login.sucesso ? "Sucesso" : "Falhou", + login.ipAddress || "-", + formatarLocalizacao(login).substring(0, 30), + login.device || "-", + login.browser || "-" + ]; + }); autoTable(doc, { startY: yPos, @@ -700,11 +754,27 @@
-
- - - - {login.matriculaOuEmail} +
+ {#if login.usuarioNome} +
+ + + + {login.usuarioNome} +
+ {/if} + {#if login.usuarioEmail || login.matriculaOuEmail} +
+ + + + + {login.usuarioEmail || (login.matriculaOuEmail && typeof login.matriculaOuEmail === 'string' ? login.matriculaOuEmail : "-")} + +
+ {:else} + - + {/if}
diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts index 3911716..8219b49 100644 --- a/packages/backend/convex/logsLogin.ts +++ b/packages/backend/convex/logsLogin.ts @@ -421,7 +421,29 @@ export const listarTodosLogins = query({ .order("desc") .take(args.limite || 50); - return logs; + // Buscar informações dos usuários quando disponível + const logsComUsuarios = await Promise.all( + logs.map(async (log) => { + let usuarioNome: string | undefined = undefined; + let usuarioEmail: string | undefined = undefined; + + if (log.usuarioId) { + const usuario = await ctx.db.get(log.usuarioId); + if (usuario) { + usuarioNome = usuario.nome; + usuarioEmail = usuario.email; + } + } + + return { + ...log, + usuarioNome, + usuarioEmail, + }; + }) + ); + + return logsComUsuarios; }, }); From 3204440a38d60b7708e6358c7e490b22e1118404 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 15:32:21 -0300 Subject: [PATCH 18/31] feat: improve login process by integrating GPS location tracking and optimizing IP address handling, enhancing user data accuracy and experience --- apps/web/src/lib/components/Sidebar.svelte | 91 +++++++++++--- apps/web/src/lib/utils/deviceInfo.ts | 91 +++++++++++++- .../(dashboard)/ti/auditoria/+page.svelte | 31 +++++ packages/backend/convex/logsLogin.ts | 119 ++++++------------ packages/backend/convex/schema.ts | 10 +- 5 files changed, 239 insertions(+), 103 deletions(-) diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 55480a2..22805e1 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -13,20 +13,7 @@ import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from 'lucide-svelte'; import { authClient } from '$lib/auth'; import { resolve } from '$app/paths'; - - // Função para obter IP público (similar ao sistema de ponto) - async function obterIPPublico(): Promise { - try { - const response = await fetch('https://api.ipify.org?format=json'); - if (response.ok) { - const data = (await response.json()) as { ip: string }; - return data.ip; - } - } catch (error) { - console.warn('Erro ao obter IP público:', error); - } - return undefined; - } + import { obterIPPublico } from '$lib/utils/deviceInfo'; let { children }: { children: Snippet } = $props(); @@ -137,11 +124,34 @@ erroLogin = ''; carregandoLogin = true; - // Obter IP público e userAgent antes do login - const [ipPublico, userAgent] = await Promise.all([ - obterIPPublico().catch(() => undefined), - Promise.resolve(typeof navigator !== 'undefined' ? navigator.userAgent : undefined) - ]); + // Obter IP público e userAgent (rápido, não bloqueia) + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined; + + // Obter IP público com timeout curto (não bloquear login) + const ipPublicoPromise = obterIPPublico().catch(() => undefined); + const ipPublicoTimeout = new Promise((resolve) => + setTimeout(() => resolve(undefined), 2000) // Timeout de 2 segundos + ); + const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]); + + // Função para coletar GPS em background (não bloqueia login) + async function coletarGPS(): Promise { + try { + const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo'); + // Usar versão rápida com timeout curto (3 segundos máximo) + const gpsPromise = obterLocalizacaoRapida(); + const gpsTimeout = new Promise<{}>((resolve) => + setTimeout(() => resolve({}), 3000) + ); + return await Promise.race([gpsPromise, gpsTimeout]); + } catch (err) { + console.warn('Erro ao obter GPS (não bloqueia login):', err); + return {}; + } + } + + // Iniciar coleta de GPS em background (não esperar) + const gpsPromise = coletarGPS(); const result = await authClient.signIn.email( { email: matricula.trim(), password: senha }, @@ -149,12 +159,30 @@ onError: async (ctx) => { // Registrar tentativa de login falha try { + // Tentar obter GPS se já estiver disponível (não esperar) + let localizacaoGPS: any = {}; + try { + localizacaoGPS = await Promise.race([ + gpsPromise, + new Promise<{}>((resolve) => setTimeout(() => resolve({}), 100)) + ]); + } catch { + // Ignorar se GPS não estiver pronto + } + await convexClient.mutation(api.logsLogin.registrarTentativaLogin, { matriculaOuEmail: matricula.trim(), sucesso: false, motivoFalha: ctx.error?.message || 'Erro desconhecido', userAgent: userAgent, ipAddress: ipPublico, + latitudeGPS: localizacaoGPS.latitude, + longitudeGPS: localizacaoGPS.longitude, + precisaoGPS: localizacaoGPS.precisao, + enderecoGPS: localizacaoGPS.endereco, + cidadeGPS: localizacaoGPS.cidade, + estadoGPS: localizacaoGPS.estado, + paisGPS: localizacaoGPS.pais, }); } catch (err) { console.error('Erro ao registrar tentativa de login falha:', err); @@ -172,6 +200,17 @@ // Aguardar um pouco para o usuário ser sincronizado no Convex await new Promise((resolve) => setTimeout(resolve, 500)); + // Tentar obter GPS se já estiver disponível (não esperar) + let localizacaoGPS: any = {}; + try { + localizacaoGPS = await Promise.race([ + gpsPromise, + new Promise<{}>((resolve) => setTimeout(() => resolve({}), 100)) + ]); + } catch { + // Ignorar se GPS não estiver pronto + } + // Buscar o usuário no Convex usando getCurrentUser const usuario = await convexClient.query(api.auth.getCurrentUser, {}); @@ -182,6 +221,13 @@ sucesso: true, userAgent: userAgent, ipAddress: ipPublico, + latitudeGPS: localizacaoGPS.latitude, + longitudeGPS: localizacaoGPS.longitude, + precisaoGPS: localizacaoGPS.precisao, + enderecoGPS: localizacaoGPS.endereco, + cidadeGPS: localizacaoGPS.cidade, + estadoGPS: localizacaoGPS.estado, + paisGPS: localizacaoGPS.pais, }); } else { // Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois) @@ -190,6 +236,13 @@ sucesso: true, userAgent: userAgent, ipAddress: ipPublico, + latitudeGPS: localizacaoGPS.latitude, + longitudeGPS: localizacaoGPS.longitude, + precisaoGPS: localizacaoGPS.precisao, + enderecoGPS: localizacaoGPS.endereco, + cidadeGPS: localizacaoGPS.cidade, + estadoGPS: localizacaoGPS.estado, + paisGPS: localizacaoGPS.pais, }); } } catch (err) { diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index 6213454..17fdcca 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -526,10 +526,97 @@ async function obterLocalizacaoMultipla(): Promise<{ }; } +/** + * Obtém localização via GPS de forma rápida (uma única leitura, sem reverse geocoding) + * Usado para login - não bloqueia o fluxo + */ +export async function obterLocalizacaoRapida(): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + endereco?: string; + cidade?: string; + estado?: string; + pais?: string; +}> { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return {}; + } + + try { + // Uma única leitura rápida com timeout curto + const leitura = await capturarLocalizacaoUnica(true, 3000); // 3 segundos máximo + + if (!leitura.latitude || !leitura.longitude || leitura.confiabilidade === 0) { + return {}; + } + + // Tentar obter endereço via reverse geocoding (com timeout curto) + let endereco = ''; + let cidade = ''; + let estado = ''; + let pais = ''; + + try { + const geocodePromise = fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${leitura.latitude}&lon=${leitura.longitude}&zoom=18&addressdetails=1`, + { + headers: { + 'User-Agent': 'SGSE-App/1.0' + } + } + ); + const geocodeTimeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 2000) + ); + + const response = await Promise.race([geocodePromise, geocodeTimeout]); + + if (response.ok) { + const data = (await response.json()) as { + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + state?: string; + country?: string; + }; + }; + if (data.address) { + const addr = data.address; + if (addr.road) { + endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; + } + cidade = addr.city || addr.town || ''; + estado = addr.state || ''; + pais = addr.country || ''; + } + } + } catch (error) { + // Ignorar erro de geocoding - não é crítico + console.warn('Erro ao obter endereço (não crítico):', error); + } + + return { + latitude: leitura.latitude, + longitude: leitura.longitude, + precisao: leitura.precisao, + endereco, + cidade, + estado, + pais + }; + } catch (error) { + console.warn('Erro ao obter localização rápida:', error); + return {}; + } +} + /** * Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing */ -async function obterLocalizacao(): Promise<{ +export async function obterLocalizacao(): Promise<{ latitude?: number; longitude?: number; precisao?: number; @@ -644,7 +731,7 @@ async function obterLocalizacao(): Promise<{ /** * Obtém IP público */ -async function obterIPPublico(): Promise { +export async function obterIPPublico(): Promise { try { const response = await fetch('https://api.ipify.org?format=json'); if (response.ok) { diff --git a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte index 8a42d70..6979b04 100644 --- a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte @@ -132,6 +132,37 @@ function formatarLocalizacao(login: any): string { if (!login || typeof login !== 'object') return "-"; + // Priorizar localização GPS (mais precisa) quando disponível + const usarGPS = login.latitudeGPS && login.longitudeGPS; + + if (usarGPS) { + const partes: string[] = []; + if (login.cidadeGPS && typeof login.cidadeGPS === 'string' && login.cidadeGPS.trim()) { + partes.push(login.cidadeGPS.trim()); + } + if (login.estadoGPS && typeof login.estadoGPS === 'string' && login.estadoGPS.trim()) { + partes.push(login.estadoGPS.trim()); + } + if (login.paisGPS && typeof login.paisGPS === 'string' && login.paisGPS.trim()) { + partes.push(login.paisGPS.trim()); + } + + if (partes.length > 0) { + return `${partes.join(", ")} (GPS)`; + } + + // Se não tiver cidade/estado/pais GPS, mas tiver endereco GPS, mostrar endereco + if (login.enderecoGPS && typeof login.enderecoGPS === 'string' && login.enderecoGPS.trim()) { + return `${login.enderecoGPS.trim()} (GPS)`; + } + + // Se tiver coordenadas mas não endereço, mostrar coordenadas + if (login.latitudeGPS && login.longitudeGPS) { + return `${login.latitudeGPS.toFixed(6)}, ${login.longitudeGPS.toFixed(6)} (GPS)`; + } + } + + // Fallback para localização por IP const partes: string[] = []; if (login.cidade && typeof login.cidade === 'string' && login.cidade.trim()) { partes.push(login.cidade.trim()); diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts index 8219b49..ff03f7c 100644 --- a/packages/backend/convex/logsLogin.ts +++ b/packages/backend/convex/logsLogin.ts @@ -2,61 +2,6 @@ import { v } from "convex/values"; import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; -/** - * Obtém geolocalização aproximada por IP usando serviço externo - * Similar ao sistema de ponto - */ -async function obterGeoPorIP(ipAddress: string): Promise<{ - latitude: number; - longitude: number; - cidade?: string; - estado?: string; - pais?: string; - endereco?: string; -} | null> { - try { - // Usar ipapi.co (gratuito, sem chave para uso limitado) - const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, { - headers: { - 'User-Agent': 'SGSE-App/1.0' - } - }); - - if (response.ok) { - const data = (await response.json()) as { - latitude?: number; - longitude?: number; - city?: string; - region?: string; - country_name?: string; - error?: boolean; - }; - - if (!data.error && data.latitude && data.longitude) { - // Montar endereço completo - const partesEndereco: string[] = []; - if (data.city) partesEndereco.push(data.city); - if (data.region) partesEndereco.push(data.region); - if (data.country_name) partesEndereco.push(data.country_name); - const endereco = partesEndereco.length > 0 ? partesEndereco.join(', ') : undefined; - - return { - latitude: data.latitude, - longitude: data.longitude, - cidade: data.city, - estado: data.region, - pais: data.country_name, - endereco - }; - } - } - } catch (error) { - console.warn('Erro ao obter geolocalização por IP:', error); - } - - return null; -} - /** * Helper para registrar tentativas de login */ @@ -98,6 +43,13 @@ export async function registrarLogin( motivoFalha?: string; ipAddress?: string; userAgent?: string; + latitudeGPS?: number; + longitudeGPS?: number; + precisaoGPS?: number; + enderecoGPS?: string; + cidadeGPS?: string; + estadoGPS?: string; + paisGPS?: string; } ) { // Extrair informações do userAgent @@ -108,25 +60,8 @@ export async function registrarLogin( // Validar e sanitizar IP antes de salvar const ipAddressValidado = validarIP(dados.ipAddress); - // Obter geolocalização por IP se disponível (de forma assíncrona para não bloquear) - let geolocalizacao: { - latitude?: number; - longitude?: number; - cidade?: string; - estado?: string; - pais?: string; - endereco?: string; - } | null = null; - - if (ipAddressValidado) { - // Obter geolocalização por IP (não bloquear se falhar) - try { - geolocalizacao = await obterGeoPorIP(ipAddressValidado); - } catch (error) { - console.warn('Erro ao obter geolocalização por IP:', error); - // Continuar sem localização se houver erro - } - } + // Nota: Geolocalização por IP removida porque fetch() não pode ser usado em mutations do Convex + // A localização GPS já é coletada no frontend e enviada diretamente await ctx.db.insert("logsLogin", { usuarioId: dados.usuarioId, @@ -138,13 +73,21 @@ export async function registrarLogin( device, browser, sistema, - // Informações de Localização - latitude: geolocalizacao?.latitude, - longitude: geolocalizacao?.longitude, - cidade: geolocalizacao?.cidade, - estado: geolocalizacao?.estado, - pais: geolocalizacao?.pais, - endereco: geolocalizacao?.endereco, + // Informações de Localização por IP (removido - usar GPS do frontend) + latitude: undefined, + longitude: undefined, + cidade: undefined, + estado: undefined, + pais: undefined, + endereco: undefined, + // Informações de Localização (GPS do navegador) + latitudeGPS: dados.latitudeGPS, + longitudeGPS: dados.longitudeGPS, + precisaoGPS: dados.precisaoGPS, + enderecoGPS: dados.enderecoGPS, + cidadeGPS: dados.cidadeGPS, + estadoGPS: dados.estadoGPS, + paisGPS: dados.paisGPS, timestamp: Date.now(), }); @@ -374,6 +317,13 @@ export const registrarTentativaLogin = mutation({ motivoFalha: v.optional(v.string()), ipAddress: v.optional(v.string()), userAgent: v.optional(v.string()), + latitudeGPS: v.optional(v.number()), + longitudeGPS: v.optional(v.number()), + precisaoGPS: v.optional(v.number()), + enderecoGPS: v.optional(v.string()), + cidadeGPS: v.optional(v.string()), + estadoGPS: v.optional(v.string()), + paisGPS: v.optional(v.string()), }, handler: async (ctx, args) => { await registrarLogin(ctx, { @@ -383,6 +333,13 @@ export const registrarTentativaLogin = mutation({ motivoFalha: args.motivoFalha, ipAddress: args.ipAddress, userAgent: args.userAgent, + latitudeGPS: args.latitudeGPS, + longitudeGPS: args.longitudeGPS, + precisaoGPS: args.precisaoGPS, + enderecoGPS: args.enderecoGPS, + cidadeGPS: args.cidadeGPS, + estadoGPS: args.estadoGPS, + paisGPS: args.paisGPS, }); return { success: true }; }, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index cfef17e..022cbcf 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -787,13 +787,21 @@ export default defineSchema({ device: v.optional(v.string()), browser: v.optional(v.string()), sistema: v.optional(v.string()), - // Informações de Localização + // Informações de Localização (por IP) latitude: v.optional(v.number()), longitude: v.optional(v.number()), endereco: v.optional(v.string()), cidade: v.optional(v.string()), estado: v.optional(v.string()), pais: v.optional(v.string()), + // Informações de Localização (GPS do navegador) + latitudeGPS: v.optional(v.number()), + longitudeGPS: v.optional(v.number()), + precisaoGPS: v.optional(v.number()), + enderecoGPS: v.optional(v.string()), + cidadeGPS: v.optional(v.string()), + estadoGPS: v.optional(v.string()), + paisGPS: v.optional(v.string()), timestamp: v.number(), }) .index("by_usuario", ["usuarioId"]) From e43f9fcf14167608f75842b2ecf05843c88c360e Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 15:40:58 -0300 Subject: [PATCH 19/31] feat: enhance ComprovantePonto component by adding logo support and restructuring document layout with auto-generated tables for employee and registration data, improving PDF output clarity and presentation --- .../components/ponto/ComprovantePonto.svelte | 145 +++++++++++------- .../routes/(dashboard)/perfil/+page.svelte | 2 +- 2 files changed, 90 insertions(+), 57 deletions(-) diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index 2fa24a9..721b06b 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -2,6 +2,7 @@ import { useQuery } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; import jsPDF from 'jspdf'; + import autoTable from 'jspdf-autotable'; import { Printer, X, User, Clock, CheckCircle2, XCircle, Calendar, MapPin } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto'; @@ -109,15 +110,16 @@ const registro = registroQuery.data; const doc = new jsPDF(); - // Logo + // Adicionar logo no canto superior esquerdo let yPosition = 20; try { - const logoImg = new Image(); - logoImg.src = logoGovPE; - await new Promise((resolve, reject) => { - logoImg.onload = () => resolve(); - logoImg.onerror = () => reject(); - setTimeout(() => reject(), 3000); + const logoImg = await new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + setTimeout(() => reject(new Error('Timeout loading logo')), 3000); + img.src = logoGovPE; }); const logoWidth = 25; @@ -125,59 +127,75 @@ const logoHeight = logoWidth * aspectRatio; doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight); - yPosition = Math.max(20, 10 + logoHeight / 2); + yPosition = 10 + logoHeight + 10; } catch (err) { - console.warn('Não foi possível carregar a logo:', err); + console.warn('Erro ao carregar logo:', err); + yPosition = 20; } - // Cabeçalho + // Cabeçalho padrão do sistema (centralizado) + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(0, 0, 0); + doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), { align: 'center' }); + doc.setFontSize(12); + doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), { align: 'center' }); + + yPosition = Math.max(yPosition, 40); + yPosition += 10; + + // Título do comprovante doc.setFontSize(16); - doc.setTextColor(41, 128, 185); + doc.setTextColor(102, 126, 234); // Cor primária padrão do sistema + doc.setFont('helvetica', 'bold'); doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' }); yPosition += 15; - // Informações do Funcionário - doc.setFontSize(12); - doc.setTextColor(0, 0, 0); - doc.setFont('helvetica', 'bold'); - doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); - doc.setFont('helvetica', 'normal'); - - yPosition += 8; - doc.setFontSize(10); - + // Informações do Funcionário em tabela + const funcionarioData: string[][] = []; + if (registro.funcionario) { if (registro.funcionario.matricula) { - doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition); - yPosition += 6; + funcionarioData.push(['Matrícula', registro.funcionario.matricula]); } - doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition); - yPosition += 6; + funcionarioData.push(['Nome', registro.funcionario.nome || '-']); if (registro.funcionario.descricaoCargo) { - doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition); - yPosition += 6; + funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]); } if (registro.funcionario.simbolo) { - doc.text( - `Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`, - 15, - yPosition - ); - yPosition += 6; + const simboloTipo = registro.funcionario.simbolo.tipo === 'cargo_comissionado' + ? 'Cargo Comissionado' + : 'Função Gratificada'; + funcionarioData.push(['Símbolo', `${registro.funcionario.simbolo.nome} (${simboloTipo})`]); } } - yPosition += 5; + if (funcionarioData.length > 0) { + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(0, 0, 0); + doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); + yPosition += 8; - // Informações do Registro - doc.setFont('helvetica', 'bold'); - doc.text('DADOS DO REGISTRO', 15, yPosition); - doc.setFont('helvetica', 'normal'); + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Informação']], + body: funcionarioData, + theme: 'striped', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 10 }, + margin: { left: 15, right: 15 } + }); - yPosition += 8; - doc.setFontSize(10); + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 10; + } + // Informações do Registro em tabela const config = configQuery?.data; const tipoLabel = config ? getTipoRegistroLabel(registro.tipo, { @@ -187,25 +205,38 @@ nomeSaida: config.nomeSaida, }) : getTipoRegistroLabel(registro.tipo); - doc.text(`Tipo: ${tipoLabel}`, 15, yPosition); - yPosition += 6; - + const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo); - doc.text(`Data e Hora: ${dataHora}`, 15, yPosition); - yPosition += 6; + + const registroData: string[][] = [ + ['Tipo', tipoLabel], + ['Data e Hora', dataHora], + ['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'], + ['Tolerância', `${registro.toleranciaMinutos} minutos`], + ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'] + ]; - doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition); - yPosition += 6; + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(0, 0, 0); + doc.text('DADOS DO REGISTRO', 15, yPosition); + yPosition += 8; - doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition); - yPosition += 6; + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Informação']], + body: registroData, + theme: 'striped', + headStyles: { fillColor: [102, 126, 234] }, + styles: { fontSize: 10 }, + margin: { left: 15, right: 15 } + }); - doc.text( - `Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`, - 15, - yPosition - ); - yPosition += 10; + type JsPDFWithAutoTable2 = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY2 = (doc as JsPDFWithAutoTable2).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY2 + 10; // Imagem capturada (se disponível) if (registro.imagemUrl) { @@ -216,8 +247,10 @@ yPosition = 20; } + doc.setFontSize(12); doc.setFont('helvetica', 'bold'); - doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' }); + doc.setTextColor(0, 0, 0); + doc.text('FOTO CAPTURADA', 15, yPosition); doc.setFont('helvetica', 'normal'); yPosition += 10; diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index a93655c..5c162ca 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -2849,7 +2849,7 @@ {#await client.query( api.ausencias.obterDetalhes, { solicitacaoId: solicitacaoAusenciaAprovar } ) then detalhes} {#if detalhes} - + +
+ + + +
+
+ + {#if abaAtiva === 'dashboard'}

Total de chamados

@@ -749,7 +783,10 @@ {/if} {/if}
+ {/if} + + {#if abaAtiva === 'chamados'}
@@ -1186,7 +1223,10 @@
{/if} + {/if} + + {#if abaAtiva === 'sla'}
@@ -1486,6 +1526,7 @@
+ {/if} {#if slaParaExcluir} From 268510bbf2322c545f76322027464898c065f23d Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 15:55:48 -0300 Subject: [PATCH 22/31] feat: update Cibersecurity SGSE title and description for clarity, and enhance Central de Chamados page by implementing filter application logic and reactivity for improved user experience --- .../src/routes/(dashboard)/ti/+page.svelte | 2 +- .../ti/central-chamados/+page.svelte | 68 +++++++++++++++---- .../(dashboard)/ti/cibersecurity/+page.svelte | 4 +- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 1a5f058..ff1113a 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -234,7 +234,7 @@ icon: 'control' }, { - title: 'Cibersecurity SGSE - Sistema de Gerenciamento de Secretaria', + title: 'Cibersecurity SGSE - Central de Segurança Cibernética', description: 'Central desegurança cibernética com detecção de DDoS, SQLi, APT, bloqueios automatizados, relatórios refinados e alertas sonoros/visuais.', ctaLabel: 'Abrir Central', diff --git a/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte b/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte index d5811ac..491ed3a 100644 --- a/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte @@ -119,24 +119,54 @@ let carregamentoToken = 0; - // Carregar chamados quando filtros mudarem + // Função para aplicar filtros + function aplicarFiltros() { + if (abaAtiva !== 'chamados') return; + + const filtros = { + status: filtroStatus === "todos" ? undefined : filtroStatus, + responsavelId: filtroResponsavel === "todos" || !filtroResponsavel ? undefined : filtroResponsavel, + setor: filtroSetor === "todos" ? undefined : filtroSetor, + }; + if (import.meta.env.DEV) { + console.log("🚀 [aplicarFiltros] Carregando chamados com filtros:", filtros); + console.log("🚀 [aplicarFiltros] Valores dos filtros:", { + filtroStatus, + filtroResponsavel, + filtroSetor + }); + } + carregarChamados(filtros); + } + + // Carregar chamados quando filtros ou aba mudarem $effect(() => { + // Só carregar se estiver na aba de chamados + if (abaAtiva !== 'chamados') return; + + // Acessar os valores dos filtros para criar dependências reativas + const status = filtroStatus; + const responsavel = filtroResponsavel; + const setor = filtroSetor; + // Pequeno delay para garantir que autenticação está configurada const timeoutId = setTimeout(() => { - const filtros = { - status: filtroStatus === "todos" ? undefined : filtroStatus, - responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel, - setor: filtroSetor === "todos" ? undefined : filtroSetor, - }; - if (import.meta.env.DEV) { - console.log("🚀 [effect] Carregando chamados com filtros:", filtros); - } - carregarChamados(filtros); + aplicarFiltros(); }, 200); return () => clearTimeout(timeoutId); }); + // Carregar chamados quando mudar para a aba de chamados + $effect(() => { + if (abaAtiva === 'chamados') { + const timeoutId = setTimeout(() => { + aplicarFiltros(); + }, 300); + return () => clearTimeout(timeoutId); + } + }); + async function carregarChamados(filtros: { status?: Ticket["status"]; responsavelId?: Id<"usuarios">; @@ -796,7 +826,11 @@

- aplicarFiltros()} + > @@ -805,13 +839,21 @@ - aplicarFiltros()} + > {#each usuariosTI as usuario (usuario._id)} {/each} - aplicarFiltros()} + > diff --git a/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte b/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte index 1a88d00..645fc91 100644 --- a/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/cibersecurity/+page.svelte @@ -4,14 +4,14 @@ - Cibersecurity SGSE - Sistema de Gerenciamento de Secretaria • Wizcard TI + Cibersecurity SGSE - Central de Segurança Cibernética

- Cibersecurity • SGSE - Sistema de Gerenciamento de Secretaria + Cibersecurity • SGSE - Central de Segurança Cibernética

Segurança Avançada

From 2fb7df8849bc846676937528f4c38475bc5d38ca Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 16:00:31 -0300 Subject: [PATCH 23/31] =?UTF-8?q?feat:=20implement=20reactive=20event=20qu?= =?UTF-8?q?ery=20for=20calendar=20in=20Atestados=20Licen=C3=A7as=20page,?= =?UTF-8?q?=20enhancing=20filtering=20capabilities=20based=20on=20user=20i?= =?UTF-8?q?nput=20for=20improved=20data=20presentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../atestados-licencas/+page.svelte | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte index d9e4470..8f255b8 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte @@ -32,9 +32,6 @@ const graficosQuery = useQuery(api.atestadosLicencas.obterDadosGraficos, { periodo: 30 }); - const eventosQuery = useQuery(api.atestadosLicencas.obterEventosCalendario, { - tipoFiltro: 'todos' - }); // Estados dos formulários // Atestado Médico @@ -82,6 +79,13 @@ let filtroDataInicio = $state(''); let filtroDataFim = $state(''); + // Query reativa para eventos do calendário - deve ser definida após os filtros + const eventosQuery = useQuery(api.atestadosLicencas.obterEventosCalendario, () => ({ + tipoFiltro: filtroTipo === 'todos' ? undefined : filtroTipo, + dataInicio: filtroDataInicio || undefined, + dataFim: filtroDataFim || undefined + })); + // Estados de loading let salvandoAtestado = $state(false); let salvandoDeclaracao = $state(false); @@ -1485,8 +1489,13 @@ {#if eventosQuery?.data} + {@const eventosFiltradosPorFuncionario = filtroFuncionario + ? eventosQuery.data.filter(e => + e.funcionarioNome.toLowerCase().includes(filtroFuncionario.toLowerCase()) + ) + : eventosQuery.data}

- +
{/if} From 4ab151bed73b5e4b3bcc1dd932aa6400315672f7 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 16:33:52 -0300 Subject: [PATCH 24/31] =?UTF-8?q?feat:=20add=20tab=20navigation=20and=20co?= =?UTF-8?q?ntent=20management=20for=20notifications=20page,=20allowing=20u?= =?UTF-8?q?sers=20to=20switch=20between=20Enviar=20Notifica=C3=A7=C3=A3o,?= =?UTF-8?q?=20Gerenciar=20Templates,=20and=20Agendamentos=20for=20improved?= =?UTF-8?q?=20organization=20and=20usability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(dashboard)/ti/notificacoes/+page.svelte | 55 +++ .../ti/notificacoes/templates/+page.svelte | 314 +++++++++++++++ packages/backend/convex/_generated/api.d.ts | 6 + packages/backend/convex/ausencias.ts | 158 ++++++-- packages/backend/convex/chamados.ts | 68 +++- packages/backend/convex/email.ts | 18 +- packages/backend/convex/schema.ts | 8 +- packages/backend/convex/templatesMensagens.ts | 380 ++++++++++++++++++ .../convex/utils/chatTemplateWrapper.ts | 46 +++ .../convex/utils/emailTemplateWrapper.ts | 185 +++++++++ .../backend/convex/utils/scanEmailSenders.ts | 189 +++++++++ 11 files changed, 1370 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte create mode 100644 packages/backend/convex/utils/chatTemplateWrapper.ts create mode 100644 packages/backend/convex/utils/emailTemplateWrapper.ts create mode 100644 packages/backend/convex/utils/scanEmailSenders.ts diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 498b105..39cef46 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -134,6 +134,9 @@ let processando = $state(false); let criandoTemplates = $state(false); let progressoEnvio = $state({ total: 0, enviados: 0, falhas: 0 }); + + // Aba ativa + let abaAtiva = $state<'enviar' | 'templates' | 'agendamentos'>('enviar'); // Estrutura de dados para logs de envio type StatusLog = 'sucesso' | 'erro' | 'fila' | 'info' | 'enviando'; @@ -1173,6 +1176,36 @@
{/if} + +
+ + + +
+ + + {#if abaAtiva === 'enviar'}
@@ -1645,7 +1678,28 @@
+ {/if} + + {#if abaAtiva === 'templates'} +
+
+
+

Templates de Mensagens

+ + Gerenciar Templates + +
+

+ Acesse a página de gerenciamento de templates para criar, editar e excluir templates de emails e + mensagens. +

+
+
+ {/if} + + + {#if abaAtiva === 'agendamentos'}
@@ -1864,6 +1918,7 @@ {/if}
+ {/if}
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte new file mode 100644 index 0000000..eaca790 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte @@ -0,0 +1,314 @@ + + +
+ +
+
+
+ + + +
+
+

Gerenciar Templates

+

Criar, editar e excluir templates de emails e mensagens

+
+
+ + + + + Voltar + +
+ + + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+

Templates ({templatesFiltrados.length})

+ + + + + Novo Template + +
+ + {#if templatesFiltrados.length === 0} +
+

Nenhum template encontrado.

+
+ {:else} +
+ + + + + + + + + + + + + {#each templatesFiltrados as template (template._id)} + + + + + + + + + {/each} + +
CódigoNomeTipoCategoriaVariáveisAções
+ {template.codigo} + +
{template.nome}
+
{template.titulo}
+
+ {#if template.tipo === 'sistema'} + Sistema + {:else} + Customizado + {/if} + + {#if template.categoria} + {template.categoria} + {:else} + - + {/if} + + {#if template.variaveis && template.variaveis.length > 0} +
+ {#each template.variaveis.slice(0, 3) as var} + {{var}} + {/each} + {#if template.variaveis.length > 3} + +{template.variaveis.length - 3} + {/if} +
+ {:else} + - + {/if} +
+
+ + + + + + {#if template.tipo === 'customizado'} + + {/if} +
+
+
+ {/if} +
+
+
+ diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 770c2b4..e6d551e 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -57,7 +57,10 @@ import type * as templatesMensagens from "../templatesMensagens.js"; import type * as times from "../times.js"; import type * as todos from "../todos.js"; import type * as usuarios from "../usuarios.js"; +import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js"; +import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js"; import type * as utils_getClientIP from "../utils/getClientIP.js"; +import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js"; import type * as verificarMatriculas from "../verificarMatriculas.js"; import type { @@ -116,7 +119,10 @@ declare const fullApi: ApiFromModules<{ times: typeof times; todos: typeof todos; usuarios: typeof usuarios; + "utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper; + "utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper; "utils/getClientIP": typeof utils_getClientIP; + "utils/scanEmailSenders": typeof utils_scanEmailSenders; verificarMatriculas: typeof verificarMatriculas; }>; diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index c48a5d0..d3981fd 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -358,20 +358,45 @@ export const criarSolicitacao = mutation({ .first(); if (gestorUsuario && funcionarioUsuario) { - // Enviar email ao gestor - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: gestorUsuario.email, - destinatarioId: gestorId, - assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`, - corpo: `

Olá ${gestorUsuario.nome},

-

O funcionário ${funcionario.nome} solicitou uma ausência:

-
    -
  • Período: ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}
  • -
  • Motivo: ${args.motivo}
  • -
-

Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

`, - enviadoPor: funcionarioUsuario._id, - }); + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email ao gestor usando template + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: gestorUsuario.email, + destinatarioId: gestorId, + templateCodigo: "ausencia_solicitada", + variaveis: { + gestorNome: gestorUsuario.nome, + funcionarioNome: funcionario.nome, + dataInicio: new Date(args.dataInicio).toLocaleDateString("pt-BR"), + dataFim: new Date(args.dataFim).toLocaleDateString("pt-BR"), + motivo: args.motivo, + urlSistema, + }, + enviadoPor: funcionarioUsuario._id, + }); + } catch (error) { + // Fallback para envio direto se template não existir + console.warn("Template ausencia_solicitada não encontrado, usando envio direto:", error); + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: gestorUsuario.email, + destinatarioId: gestorId, + assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`, + corpo: `

Olá ${gestorUsuario.nome},

+

O funcionário ${funcionario.nome} solicitou uma ausência:

+
    +
  • Período: ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}
  • +
  • Motivo: ${args.motivo}
  • +
+

Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

`, + enviadoPor: funcionarioUsuario._id, + }); + } // Criar ou obter conversa entre gestor e funcionário const conversasExistentes = await ctx.db @@ -475,19 +500,44 @@ export const aprovar = mutation({ const gestorUsuario = await ctx.db.get(args.gestorId); if (gestorUsuario) { - // Enviar email ao funcionário - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: funcionarioUsuario.email, - destinatarioId: funcionarioUsuario._id, - assunto: "Solicitação de Ausência Aprovada", - corpo: `

Olá ${funcionarioUsuario.nome},

-

Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:

-
    -
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • -
  • Motivo: ${solicitacao.motivo}
  • -
`, - enviadoPor: args.gestorId, - }); + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email ao funcionário usando template + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + templateCodigo: "ausencia_aprovada", + variaveis: { + funcionarioNome: funcionarioUsuario.nome, + gestorNome: gestorUsuario.nome, + dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"), + dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"), + motivo: solicitacao.motivo, + urlSistema, + }, + enviadoPor: args.gestorId, + }); + } catch (error) { + // Fallback para envio direto se template não existir + console.warn("Template ausencia_aprovada não encontrado, usando envio direto:", error); + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + assunto: "Solicitação de Ausência Aprovada", + corpo: `

Olá ${funcionarioUsuario.nome},

+

Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:

+
    +
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • +
  • Motivo: ${solicitacao.motivo}
  • +
`, + enviadoPor: args.gestorId, + }); + } // Criar ou obter conversa const conversasExistentes = await ctx.db @@ -593,20 +643,46 @@ export const reprovar = mutation({ const gestorUsuario = await ctx.db.get(args.gestorId); if (gestorUsuario) { - // Enviar email ao funcionário - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: funcionarioUsuario.email, - destinatarioId: funcionarioUsuario._id, - assunto: "Solicitação de Ausência Reprovada", - corpo: `

Olá ${funcionarioUsuario.nome},

-

Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:

-
    -
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • -
  • Motivo: ${solicitacao.motivo}
  • -
  • Motivo da Reprovação: ${args.motivoReprovacao}
  • -
`, - enviadoPor: args.gestorId, - }); + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email ao funcionário usando template + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + templateCodigo: "ausencia_reprovada", + variaveis: { + funcionarioNome: funcionarioUsuario.nome, + gestorNome: gestorUsuario.nome, + dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"), + dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"), + motivo: solicitacao.motivo, + motivoReprovacao: args.motivoReprovacao, + urlSistema, + }, + enviadoPor: args.gestorId, + }); + } catch (error) { + // Fallback para envio direto se template não existir + console.warn("Template ausencia_reprovada não encontrado, usando envio direto:", error); + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + assunto: "Solicitação de Ausência Reprovada", + corpo: `

Olá ${funcionarioUsuario.nome},

+

Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:

+
    +
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • +
  • Motivo: ${solicitacao.motivo}
  • +
  • Motivo da Reprovação: ${args.motivoReprovacao}
  • +
`, + enviadoPor: args.gestorId, + }); + } // Criar ou obter conversa const conversasExistentes = await ctx.db diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts index f25df21..f0a9776 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -121,15 +121,38 @@ async function registrarNotificacoes( ) { const { ticket, titulo, mensagem, usuarioEvento } = params; + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + // Notificar solicitante if (ticket.solicitanteEmail) { - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: ticket.solicitanteEmail, - destinatarioId: ticket.solicitanteId, - assunto: `${titulo} - Chamado ${ticket.numero}`, - corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, - enviadoPor: usuarioEvento, - }); + // Tentar usar template, senão usar envio direto + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: ticket.solicitanteEmail, + destinatarioId: ticket.solicitanteId, + templateCodigo: "chamado_atualizado", + variaveis: { + solicitante: ticket.solicitanteNome || "Usuário", + numeroTicket: ticket.numero, + mensagem: mensagem, + urlSistema, + }, + enviadoPor: usuarioEvento, + }); + } catch (error) { + // Fallback para envio direto + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: ticket.solicitanteEmail, + destinatarioId: ticket.solicitanteId, + assunto: `${titulo} - Chamado ${ticket.numero}`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, + enviadoPor: usuarioEvento, + }); + } } await ctx.db.insert("notificacoes", { @@ -147,13 +170,30 @@ async function registrarNotificacoes( if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) { const responsavel = await ctx.db.get(ticket.responsavelId); if (responsavel?.email) { - await ctx.runMutation(api.email.enfileirarEmail, { - destinatario: responsavel.email, - destinatarioId: ticket.responsavelId, - assunto: `${titulo} - Chamado ${ticket.numero}`, - corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, - enviadoPor: usuarioEvento, - }); + // Tentar usar template, senão usar envio direto + try { + await ctx.runAction(api.email.enviarEmailComTemplate, { + destinatario: responsavel.email, + destinatarioId: ticket.responsavelId, + templateCodigo: "chamado_atualizado", + variaveis: { + solicitante: ticket.solicitanteNome || "Usuário", + numeroTicket: ticket.numero, + mensagem: mensagem, + urlSistema, + }, + enviadoPor: usuarioEvento, + }); + } catch (error) { + // Fallback para envio direto + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: responsavel.email, + destinatarioId: ticket.responsavelId, + assunto: `${titulo} - Chamado ${ticket.numero}`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`, + enviadoPor: usuarioEvento, + }); + } } await ctx.db.insert("notificacoes", { diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index c136376..b178228 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server"; import { internal, api } from "./_generated/api"; import { renderizarTemplate } from "./templatesMensagens"; +import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper"; import type { Doc, Id } from "./_generated/dataModel"; // ========== INTERNAL QUERIES ========== @@ -221,12 +222,27 @@ export const enviarEmailComTemplate = action({ const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate); const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate); + // Usar htmlCorpo se disponível, senão gerar do corpo + let corpoHTML = template.htmlCorpo; + if (corpoHTML) { + // Renderizar variáveis no HTML + corpoHTML = renderizarTemplate(corpoHTML, variaveisTemplate); + } else { + // Gerar HTML do corpo renderizado + if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) { + corpoHTML = wrapEmailHTML(corpoRenderizado, tituloRenderizado); + } else { + const corpoHTMLFormatado = textToHTML(corpoRenderizado); + corpoHTML = wrapEmailHTML(corpoHTMLFormatado, tituloRenderizado); + } + } + // Enfileirar email via mutation const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, { destinatario: args.destinatario, destinatarioId: args.destinatarioId, assunto: tituloRenderizado, - corpo: corpoRenderizado, + corpo: corpoHTML, // Usar HTML completo templateId: template._id, // template._id sempre existe se template não é null enviadoPor: args.enviadoPor, agendadaPara: args.agendadaPara, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 022cbcf..76294be 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -851,13 +851,19 @@ export default defineSchema({ ), titulo: v.string(), corpo: v.string(), // pode ter variáveis {{variavel}} + htmlCorpo: v.optional(v.string()), // versão HTML do corpo (com wrapper) variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.] + categoria: v.optional( + v.union(v.literal("email"), v.literal("chat"), v.literal("ambos")) + ), // categoria do template + tags: v.optional(v.array(v.string())), // tags para organização criadoPor: v.optional(v.id("usuarios")), criadoEm: v.number(), }) .index("by_codigo", ["codigo"]) .index("by_tipo", ["tipo"]) - .index("by_criado_por", ["criadoPor"]), + .index("by_criado_por", ["criadoPor"]) + .index("by_categoria", ["categoria"]), // Configuração de Email/SMTP configuracaoEmail: defineTable({ diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index 1b92ecf..a7fec7a 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -2,6 +2,7 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { registrarAtividade } from "./logsAtividades"; import { Doc } from "./_generated/dataModel"; +import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper"; /** * Listar todos os templates @@ -40,7 +41,10 @@ export const criarTemplate = mutation({ nome: v.string(), titulo: v.string(), corpo: v.string(), + htmlCorpo: v.optional(v.string()), variaveis: v.optional(v.array(v.string())), + categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))), + tags: v.optional(v.array(v.string())), criadoPorId: v.id("usuarios"), }, returns: v.union( @@ -58,6 +62,18 @@ export const criarTemplate = mutation({ return { sucesso: false as const, erro: "Código de template já existe" }; } + // Gerar HTML se não fornecido + let htmlCorpo = args.htmlCorpo; + if (!htmlCorpo) { + // Se o corpo já for HTML, usar diretamente, senão converter + if (args.corpo.includes("<") && args.corpo.includes(">")) { + htmlCorpo = wrapEmailHTML(args.corpo, args.titulo); + } else { + const corpoHTML = textToHTML(args.corpo); + htmlCorpo = wrapEmailHTML(corpoHTML, args.titulo); + } + } + // Criar template const templateId = await ctx.db.insert("templatesMensagens", { codigo: args.codigo, @@ -65,7 +81,10 @@ export const criarTemplate = mutation({ tipo: "customizado", titulo: args.titulo, corpo: args.corpo, + htmlCorpo, variaveis: args.variaveis, + categoria: args.categoria || "email", + tags: args.tags, criadoPor: args.criadoPorId, criadoEm: Date.now(), }); @@ -93,7 +112,10 @@ export const editarTemplate = mutation({ nome: v.optional(v.string()), titulo: v.optional(v.string()), corpo: v.optional(v.string()), + htmlCorpo: v.optional(v.string()), variaveis: v.optional(v.array(v.string())), + categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))), + tags: v.optional(v.array(v.string())), editadoPorId: v.id("usuarios"), }, returns: v.union( @@ -116,7 +138,21 @@ export const editarTemplate = mutation({ if (args.nome !== undefined) updates.nome = args.nome; if (args.titulo !== undefined) updates.titulo = args.titulo; if (args.corpo !== undefined) updates.corpo = args.corpo; + if (args.htmlCorpo !== undefined) { + updates.htmlCorpo = args.htmlCorpo; + } else if (args.corpo !== undefined) { + // Se corpo foi atualizado mas htmlCorpo não, regenerar HTML + const titulo = args.titulo || template.titulo; + if (args.corpo.includes("<") && args.corpo.includes(">")) { + updates.htmlCorpo = wrapEmailHTML(args.corpo, titulo); + } else { + const corpoHTML = textToHTML(args.corpo); + updates.htmlCorpo = wrapEmailHTML(corpoHTML, titulo); + } + } if (args.variaveis !== undefined) updates.variaveis = args.variaveis; + if (args.categoria !== undefined) updates.categoria = args.categoria; + if (args.tags !== undefined) updates.tags = args.tags; await ctx.db.patch(args.templateId, updates); @@ -396,6 +432,33 @@ export const criarTemplatesPadrao = mutation({ + "
", variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"], }, + { + codigo: "ausencia_solicitada", + nome: "Ausência Solicitada", + titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}", + corpo: "Olá {{gestorNome}},\n\nO funcionário {{funcionarioNome}} solicitou uma ausência:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.", + variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo", "urlSistema"], + categoria: "email" as const, + tags: ["ausencia", "solicitacao", "gestao"], + }, + { + codigo: "ausencia_aprovada", + nome: "Ausência Aprovada", + titulo: "Solicitação de Ausência Aprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi aprovada pelo gestor {{gestorNome}}:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "urlSistema"], + categoria: "email" as const, + tags: ["ausencia", "aprovacao", "gestao"], + }, + { + codigo: "ausencia_reprovada", + nome: "Ausência Reprovada", + titulo: "Solicitação de Ausência Reprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi reprovada pelo gestor {{gestorNome}}:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
  • Motivo da Reprovação: {{motivoReprovacao}}
", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao", "urlSistema"], + categoria: "email" as const, + tags: ["ausencia", "reprovacao", "gestao"], + }, ]; for (const template of templatesPadrao) { @@ -418,4 +481,321 @@ export const criarTemplatesPadrao = mutation({ }, }); +/** + * Atualizar HTML de um template + */ +export const atualizarTemplateHTML = mutation({ + args: { + templateId: v.id("templatesMensagens"), + htmlCorpo: v.string(), + editadoPorId: v.id("usuarios"), + }, + returns: v.union( + v.object({ sucesso: v.literal(true) }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) { + return { sucesso: false as const, erro: "Template não encontrado" }; + } + + // Não permite editar templates do sistema + if (template.tipo === "sistema") { + return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" }; + } + + await ctx.db.patch(args.templateId, { + htmlCorpo: args.htmlCorpo, + }); + + await registrarAtividade( + ctx, + args.editadoPorId, + "editar", + "templates", + JSON.stringify({ templateId: args.templateId, campo: "htmlCorpo" }), + args.templateId + ); + + return { sucesso: true as const }; + }, +}); + +/** + * Preview de template renderizado com variáveis de teste + */ +export const previewTemplate = query({ + args: { + templateId: v.id("templatesMensagens"), + variaveisTeste: v.optional(v.record(v.string(), v.string())), + }, + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) { + return null; + } + + // Variáveis padrão para teste + const variaveisPadrao: Record = { + nome: "João Silva", + matricula: "12345", + senha: "Senha123!", + motivo: "Exemplo de motivo", + remetente: "Maria Santos", + mensagem: "Esta é uma mensagem de exemplo para preview do template.", + conversaId: "abc123", + urlSistema: getBaseUrl(), + solicitante: "João Silva", + numeroTicket: "TKT-2024-001", + prioridade: "Alta", + categoria: "Suporte Técnico", + responsavel: "Maria Santos", + descricao: "Exemplo de descrição de chamado", + destinario: "João Silva", + tipoPrazo: "resolução", + prazo: "24 horas", + status: "Em andamento", + rotaAcesso: "/ti/central-chamados", + titulo: "Título de Exemplo", + }; + + const variaveis = { ...variaveisPadrao, ...(args.variaveisTeste || {}) }; + + // Renderizar título e corpo + const tituloRenderizado = renderizarTemplate(template.titulo, variaveis); + const corpoRenderizado = renderizarTemplate(template.corpo, variaveis); + + // Se tiver htmlCorpo, usar ele, senão gerar do corpo + let htmlFinal = template.htmlCorpo; + if (!htmlFinal) { + if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) { + htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado); + } else { + const corpoHTML = textToHTML(corpoRenderizado); + htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado); + } + } else { + htmlFinal = renderizarTemplate(htmlFinal, variaveis); + } + + return { + titulo: tituloRenderizado, + corpo: corpoRenderizado, + html: htmlFinal, + variaveisUsadas: template.variaveis || [], + }; + }, +}); + +/** + * Função auxiliar para obter URL base + */ +function getBaseUrl(): string { + const url = process.env.FRONTEND_URL || "http://localhost:5173"; + if (!url.match(/^https?:\/\//i)) { + return `http://${url}`; + } + return url; +} + +/** + * Exportar templates (JSON) + */ +export const exportarTemplates = query({ + args: { + templateIds: v.optional(v.array(v.id("templatesMensagens"))), + }, + handler: async (ctx, args) => { + let templates; + + if (args.templateIds && args.templateIds.length > 0) { + templates = await Promise.all( + args.templateIds.map((id) => ctx.db.get(id)) + ); + templates = templates.filter((t): t is Doc<"templatesMensagens"> => t !== null); + } else { + templates = await ctx.db.query("templatesMensagens").collect(); + } + + // Remover campos internos e retornar apenas dados exportáveis + return templates.map((t) => ({ + codigo: t.codigo, + nome: t.nome, + tipo: t.tipo, + titulo: t.titulo, + corpo: t.corpo, + htmlCorpo: t.htmlCorpo, + variaveis: t.variaveis, + categoria: t.categoria, + tags: t.tags, + })); + }, +}); + +/** + * Importar templates (JSON) + */ +export const importarTemplates = mutation({ + args: { + templates: v.array( + v.object({ + codigo: v.string(), + nome: v.string(), + tipo: v.optional(v.union(v.literal("sistema"), v.literal("customizado"))), + titulo: v.string(), + corpo: v.string(), + htmlCorpo: v.optional(v.string()), + variaveis: v.optional(v.array(v.string())), + categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))), + tags: v.optional(v.array(v.string())), + }) + ), + importadoPorId: v.id("usuarios"), + sobrescrever: v.optional(v.boolean()), + }, + returns: v.object({ + sucesso: v.boolean(), + importados: v.number(), + atualizados: v.number(), + erros: v.array(v.string()), + }), + handler: async (ctx, args) => { + let importados = 0; + let atualizados = 0; + const erros: string[] = []; + + for (const templateData of args.templates) { + try { + const existente = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", templateData.codigo)) + .first(); + + if (existente) { + if (args.sobrescrever && existente.tipo === "customizado") { + // Atualizar template existente + await ctx.db.patch(existente._id, { + nome: templateData.nome, + titulo: templateData.titulo, + corpo: templateData.corpo, + htmlCorpo: templateData.htmlCorpo, + variaveis: templateData.variaveis, + categoria: templateData.categoria, + tags: templateData.tags, + }); + atualizados++; + } else { + erros.push(`Template ${templateData.codigo} já existe e sobrescrever está desabilitado`); + } + } else { + // Criar novo template + const tipo = templateData.tipo || "customizado"; + + // Gerar HTML se não fornecido + let htmlCorpo = templateData.htmlCorpo; + if (!htmlCorpo) { + if (templateData.corpo.includes("<") && templateData.corpo.includes(">")) { + htmlCorpo = wrapEmailHTML(templateData.corpo, templateData.titulo); + } else { + const corpoHTML = textToHTML(templateData.corpo); + htmlCorpo = wrapEmailHTML(corpoHTML, templateData.titulo); + } + } + + await ctx.db.insert("templatesMensagens", { + codigo: templateData.codigo, + nome: templateData.nome, + tipo, + titulo: templateData.titulo, + corpo: templateData.corpo, + htmlCorpo, + variaveis: templateData.variaveis, + categoria: templateData.categoria || "email", + tags: templateData.tags, + criadoPor: args.importadoPorId, + criadoEm: Date.now(), + }); + importados++; + } + } catch (error) { + const erroMsg = error instanceof Error ? error.message : String(error); + erros.push(`Erro ao importar ${templateData.codigo}: ${erroMsg}`); + } + } + + await registrarAtividade( + ctx, + args.importadoPorId, + "importar", + "templates", + JSON.stringify({ importados, atualizados, erros: erros.length }), + undefined + ); + + return { + sucesso: erros.length === 0, + importados, + atualizados, + erros, + }; + }, +}); + +/** + * Duplicar template + */ +export const duplicarTemplate = mutation({ + args: { + templateId: v.id("templatesMensagens"), + novoCodigo: v.string(), + novoNome: v.optional(v.string()), + criadoPorId: v.id("usuarios"), + }, + returns: v.union( + v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + if (!template) { + return { sucesso: false as const, erro: "Template não encontrado" }; + } + + // Verificar se novo código já existe + const existente = await ctx.db + .query("templatesMensagens") + .withIndex("by_codigo", (q) => q.eq("codigo", args.novoCodigo)) + .first(); + + if (existente) { + return { sucesso: false as const, erro: "Código de template já existe" }; + } + + const templateId = await ctx.db.insert("templatesMensagens", { + codigo: args.novoCodigo, + nome: args.novoNome || `${template.nome} (Cópia)`, + tipo: "customizado", + titulo: template.titulo, + corpo: template.corpo, + htmlCorpo: template.htmlCorpo, + variaveis: template.variaveis, + categoria: template.categoria, + tags: template.tags, + criadoPor: args.criadoPorId, + criadoEm: Date.now(), + }); + + await registrarAtividade( + ctx, + args.criadoPorId, + "duplicar", + "templates", + JSON.stringify({ templateId, codigo: args.novoCodigo, originalId: args.templateId }), + templateId + ); + + return { sucesso: true as const, templateId }; + }, +}); diff --git a/packages/backend/convex/utils/chatTemplateWrapper.ts b/packages/backend/convex/utils/chatTemplateWrapper.ts new file mode 100644 index 0000000..7c06eef --- /dev/null +++ b/packages/backend/convex/utils/chatTemplateWrapper.ts @@ -0,0 +1,46 @@ +/** + * Wrapper para padronizar mensagens de chat do SGSE + */ + +/** + * Formata mensagem de chat com prefixo padronizado quando necessário + * @param conteudo - Conteúdo da mensagem + * @param tipo - Tipo da mensagem (opcional) + * @returns Mensagem formatada + */ +export function wrapChatMessage(conteudo: string, tipo?: string): string { + // Se já tiver formatação especial, retornar como está + if (conteudo.includes('[SGSE]') || conteudo.includes('[Sistema]')) { + return conteudo; + } + + // Para mensagens do sistema, adicionar prefixo + if (tipo === 'sistema' || tipo === 'notificacao') { + return `[SGSE] ${conteudo}`; + } + + return conteudo; +} + +/** + * Formata mensagem de chat com informações estruturadas + * @param titulo - Título da notificação + * @param conteudo - Conteúdo da mensagem + * @param acao - Ação sugerida (opcional) + * @returns Mensagem formatada + */ +export function formatChatNotification( + titulo: string, + conteudo: string, + acao?: string +): string { + let mensagem = `🔔 ${titulo}\n\n${conteudo}`; + + if (acao) { + mensagem += `\n\n💡 ${acao}`; + } + + return mensagem; +} + + diff --git a/packages/backend/convex/utils/emailTemplateWrapper.ts b/packages/backend/convex/utils/emailTemplateWrapper.ts new file mode 100644 index 0000000..0ba5e2b --- /dev/null +++ b/packages/backend/convex/utils/emailTemplateWrapper.ts @@ -0,0 +1,185 @@ +/** + * Wrapper HTML para templates de email do SGSE + * Aplica estilo governamental profissional com logo e assinatura padronizada + */ + +/** + * Obtém a URL base do sistema para uso em links de email + */ +function getBaseUrl(): string { + // Em produção, usar variável de ambiente + const url = process.env.FRONTEND_URL || "http://localhost:5173"; + // Garantir que tenha protocolo + if (!url.match(/^https?:\/\//i)) { + return `http://${url}`; + } + return url; +} + +/** + * Gera o HTML do header com logo do Governo de PE + */ +function generateHeader(): string { + const baseUrl = getBaseUrl(); + return ` + + + + +
+ + + + +
+ Governo de Pernambuco +
+
+ `; +} + +/** + * Gera o HTML do footer com assinatura SGSE + */ +function generateFooter(): string { + const baseUrl = getBaseUrl(); + const currentYear = new Date().getFullYear(); + + return ` + + + + +
+ + + + +
+

+ SGSE - Sistema de Gerenciamento de Secretaria +

+

+ Secretaria de Esportes do Estado de Pernambuco +

+

+ Este é um email automático do sistema. Por favor, não responda diretamente a este email. +

+
+

+ © ${currentYear} Secretaria de Esportes - Governo de Pernambuco. Todos os direitos reservados. +

+

+ Acessar Sistema | + Central de Notificações +

+
+
+ `; +} + +/** + * Envolve o conteúdo HTML do email com template profissional governamental + * @param conteudoHTML - Conteúdo HTML do corpo do email + * @param titulo - Título do email (usado no meta) + * @returns HTML completo do email pronto para envio + */ +export function wrapEmailHTML(conteudoHTML: string, titulo?: string): string { + // Se o conteúdo já estiver dentro de um wrapper completo, retornar como está + if (conteudoHTML.includes('') || conteudoHTML.includes('${conteudoProcessado}

`; + } + + const header = generateHeader(); + const footer = generateFooter(); + const emailTitle = titulo || "Notificação do SGSE"; + + return ` + + + + + + + ${emailTitle} + + + + + + + + +
+ + + + ${header} + + + + + + + + + + +
+ + + + +
+ ${conteudoProcessado} +
+
+ ${footer} +
+ + + + + + +
+

Se você não solicitou este email, pode ignorá-lo com segurança.

+
+
+ + + `.trim(); +} + +/** + * Converte texto plano em HTML básico + * @param texto - Texto plano + * @returns HTML formatado + */ +export function textToHTML(texto: string): string { + return texto + .split('\n') + .map(linha => { + const linhaTrim = linha.trim(); + if (!linhaTrim) return '
'; + // Detectar links + const linkRegex = /(https?:\/\/[^\s]+)/g; + const linhaComLinks = linhaTrim.replace(linkRegex, '$1'); + return `

${linhaComLinks}

`; + }) + .join(''); +} + + diff --git a/packages/backend/convex/utils/scanEmailSenders.ts b/packages/backend/convex/utils/scanEmailSenders.ts new file mode 100644 index 0000000..14ec74d --- /dev/null +++ b/packages/backend/convex/utils/scanEmailSenders.ts @@ -0,0 +1,189 @@ +/** + * Scanner automático de envios de email e mensagens no código + * Identifica todos os locais onde emails são enviados para gerar templates + */ + +import { Doc } from "../_generated/dataModel"; + +export interface EmailSendLocation { + arquivo: string; + funcao: string; + tipo: "enfileirarEmail" | "enviarEmailComTemplate" | "enviarMensagem" | "html_inline"; + linha?: number; + contexto?: string; + assunto?: string; + corpo?: string; + templateCodigo?: string; + variaveis?: string[]; +} + +/** + * Lista de locais conhecidos onde emails são enviados + * Este é um mapeamento manual baseado na análise do código + */ +export const LOCAIS_ENVIO_EMAIL: EmailSendLocation[] = [ + // Chamados + { + arquivo: "packages/backend/convex/chamados.ts", + funcao: "registrarNotificacoes", + tipo: "enfileirarEmail", + contexto: "Notificação ao solicitante quando chamado é criado/atualizado", + assunto: "Chamado {{numeroTicket}} - {{titulo}}", + corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria", + variaveis: ["numeroTicket", "titulo", "mensagem"], + }, + { + arquivo: "packages/backend/convex/chamados.ts", + funcao: "registrarNotificacoes", + tipo: "enfileirarEmail", + contexto: "Notificação ao responsável quando chamado é atualizado", + assunto: "Chamado {{numeroTicket}} - {{titulo}}", + corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria", + variaveis: ["numeroTicket", "titulo", "mensagem"], + }, + + // Ausências + { + arquivo: "packages/backend/convex/ausencias.ts", + funcao: "solicitar", + tipo: "enfileirarEmail", + contexto: "Notificação ao gestor quando funcionário solicita ausência", + assunto: "Nova Solicitação de Ausência - {{funcionarioNome}}", + corpo: "Olá {{gestorNome}},\n\nO funcionário {{funcionarioNome}} solicitou uma ausência:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.", + variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"], + }, + { + arquivo: "packages/backend/convex/ausencias.ts", + funcao: "aprovar", + tipo: "enfileirarEmail", + contexto: "Notificação ao funcionário quando ausência é aprovada", + assunto: "Solicitação de Ausência Aprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi aprovada pelo gestor {{gestorNome}}:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"], + }, + { + arquivo: "packages/backend/convex/ausencias.ts", + funcao: "reprovar", + tipo: "enfileirarEmail", + contexto: "Notificação ao funcionário quando ausência é reprovada", + assunto: "Solicitação de Ausência Reprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi reprovada pelo gestor {{gestorNome}}:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
  • Motivo da Reprovação: {{motivoReprovacao}}
", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"], + }, + + // Chat + { + arquivo: "packages/backend/convex/chat.ts", + funcao: "enviarMensagem", + tipo: "enviarEmailComTemplate", + contexto: "Email quando usuário recebe nova mensagem no chat (usuário offline)", + templateCodigo: "chat_mensagem", + variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], + }, + { + arquivo: "packages/backend/convex/chat.ts", + funcao: "enviarMensagem", + tipo: "enviarEmailComTemplate", + contexto: "Email quando usuário é mencionado no chat (usuário offline)", + templateCodigo: "chat_mencao", + variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], + }, + + // Painel de Notificações + { + arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte", + funcao: "enviarNotificacao", + tipo: "enfileirarEmail", + contexto: "Envio manual de notificação via painel de TI", + assunto: "Notificação do Sistema", + corpo: "{{mensagemPersonalizada}}", + variaveis: ["mensagemPersonalizada"], + }, + { + arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte", + funcao: "enviarNotificacao", + tipo: "enviarEmailComTemplate", + contexto: "Envio manual de notificação usando template via painel de TI", + templateCodigo: "{{templateCodigo}}", + variaveis: ["nome", "matricula"], + }, +]; + +/** + * Sugestões de templates baseadas nos locais de envio encontrados + */ +export interface TemplateSuggestion { + codigo: string; + nome: string; + titulo: string; + corpo: string; + categoria: "email" | "chat" | "ambos"; + variaveis: string[]; + tags: string[]; + origem: string; +} + +/** + * Gerar sugestões de templates baseadas nos locais de envio + */ +export function gerarSugestoesTemplates(): TemplateSuggestion[] { + const sugestoes: TemplateSuggestion[] = []; + + // Template para ausência solicitada + sugestoes.push({ + codigo: "ausencia_solicitada", + nome: "Ausência Solicitada", + titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}", + corpo: "Olá {{gestorNome}},\n\nO funcionário {{funcionarioNome}} solicitou uma ausência:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.", + categoria: "email", + variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"], + tags: ["ausencia", "solicitacao", "gestao"], + origem: "ausencias.ts - solicitar", + }); + + // Template para ausência aprovada + sugestoes.push({ + codigo: "ausencia_aprovada", + nome: "Ausência Aprovada", + titulo: "Solicitação de Ausência Aprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi aprovada pelo gestor {{gestorNome}}:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
", + categoria: "email", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"], + tags: ["ausencia", "aprovacao", "gestao"], + origem: "ausencias.ts - aprovar", + }); + + // Template para ausência reprovada + sugestoes.push({ + codigo: "ausencia_reprovada", + nome: "Ausência Reprovada", + titulo: "Solicitação de Ausência Reprovada", + corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi reprovada pelo gestor {{gestorNome}}:\n\n
  • Período: {{dataInicio}} até {{dataFim}}
  • Motivo: {{motivo}}
  • Motivo da Reprovação: {{motivoReprovacao}}
", + categoria: "email", + variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"], + tags: ["ausencia", "reprovacao", "gestao"], + origem: "ausencias.ts - reprovar", + }); + + // Template genérico para notificações de chamados + sugestoes.push({ + codigo: "chamado_notificacao", + nome: "Notificação de Chamado", + titulo: "Chamado {{numeroTicket}} - {{titulo}}", + corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria", + categoria: "email", + variaveis: ["numeroTicket", "titulo", "mensagem"], + tags: ["chamado", "notificacao", "suporte"], + origem: "chamados.ts - registrarNotificacoes", + }); + + return sugestoes; +} + +/** + * Obter todos os locais de envio de email + */ +export function obterLocaisEnvio(): EmailSendLocation[] { + return LOCAIS_ENVIO_EMAIL; +} + From 4e3feca84de04153d7e2c71b978195da28df8c06 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 16:47:48 -0300 Subject: [PATCH 25/31] refactor: rename variable in notification template rendering for improved clarity and consistency --- .../routes/(dashboard)/ti/notificacoes/templates/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte index eaca790..3e5ad68 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte @@ -243,8 +243,8 @@ {#if template.variaveis && template.variaveis.length > 0}
- {#each template.variaveis.slice(0, 3) as var} - {{var}} + {#each template.variaveis.slice(0, 3) as variavel} + {{variavel}} {/each} {#if template.variaveis.length > 3} +{template.variaveis.length - 3} From d9e78079c8c905e681b5778f88d2810e49cb4344 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 05:45:19 -0300 Subject: [PATCH 26/31] feat: update email notification handling to use scheduler for template sending, with improved error handling for fallback scenarios --- .../notificacoes/templates/[id]/+page.svelte | 267 ++++++++++++++++++ .../notificacoes/templates/novo/+page.svelte | 243 ++++++++++++++++ packages/backend/convex/ausencias.ts | 33 ++- packages/backend/convex/chamados.ts | 12 +- 4 files changed, 541 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte new file mode 100644 index 0000000..6430ee2 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte @@ -0,0 +1,267 @@ + + +
+
+
+
+ + + +
+
+

Editar Template

+

+ Atualize as informações do template selecionado. Templates de sistema não podem ser + editados. +

+
+
+ Voltar +
+ + {#if !template} +
+ Template não encontrado. +
+ {:else} + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + + +
+
+ + +
+
+ +
+ Cancelar + +
+
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte new file mode 100644 index 0000000..c29def9 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte @@ -0,0 +1,243 @@ + + +
+
+
+
+ + + +
+
+

Novo Template

+

+ Crie um template de email ou mensagem para reutilizar no sistema. +

+
+
+ Voltar +
+ + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + + +
+
+ + +
+
+ +
+ Cancelar + +
+
+
+
diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index d3981fd..89078d2 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -364,9 +364,9 @@ export const criarSolicitacao = mutation({ urlSistema = `http://${urlSistema}`; } - // Enviar email ao gestor usando template + // Enviar email ao gestor usando template (agendado via scheduler) try { - await ctx.runAction(api.email.enviarEmailComTemplate, { + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: gestorUsuario.email, destinatarioId: gestorId, templateCodigo: "ausencia_solicitada", @@ -381,8 +381,11 @@ export const criarSolicitacao = mutation({ enviadoPor: funcionarioUsuario._id, }); } catch (error) { - // Fallback para envio direto se template não existir - console.warn("Template ausencia_solicitada não encontrado, usando envio direto:", error); + // Fallback para envio direto se houver erro ao agendar ou processar o template + console.warn( + "Erro ao agendar envio de email com template ausencia_solicitada, usando envio direto:", + error, + ); await ctx.runMutation(api.email.enfileirarEmail, { destinatario: gestorUsuario.email, destinatarioId: gestorId, @@ -506,9 +509,9 @@ export const aprovar = mutation({ urlSistema = `http://${urlSistema}`; } - // Enviar email ao funcionário usando template + // Enviar email ao funcionário usando template (agendado via scheduler) try { - await ctx.runAction(api.email.enviarEmailComTemplate, { + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: funcionarioUsuario.email, destinatarioId: funcionarioUsuario._id, templateCodigo: "ausencia_aprovada", @@ -523,8 +526,11 @@ export const aprovar = mutation({ enviadoPor: args.gestorId, }); } catch (error) { - // Fallback para envio direto se template não existir - console.warn("Template ausencia_aprovada não encontrado, usando envio direto:", error); + // Fallback para envio direto se houver erro ao agendar ou processar o template + console.warn( + "Erro ao agendar envio de email com template ausencia_aprovada, usando envio direto:", + error, + ); await ctx.runMutation(api.email.enfileirarEmail, { destinatario: funcionarioUsuario.email, destinatarioId: funcionarioUsuario._id, @@ -649,9 +655,9 @@ export const reprovar = mutation({ urlSistema = `http://${urlSistema}`; } - // Enviar email ao funcionário usando template + // Enviar email ao funcionário usando template (agendado via scheduler) try { - await ctx.runAction(api.email.enviarEmailComTemplate, { + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: funcionarioUsuario.email, destinatarioId: funcionarioUsuario._id, templateCodigo: "ausencia_reprovada", @@ -667,8 +673,11 @@ export const reprovar = mutation({ enviadoPor: args.gestorId, }); } catch (error) { - // Fallback para envio direto se template não existir - console.warn("Template ausencia_reprovada não encontrado, usando envio direto:", error); + // Fallback para envio direto se houver erro ao agendar ou processar o template + console.warn( + "Erro ao agendar envio de email com template ausencia_reprovada, usando envio direto:", + error, + ); await ctx.runMutation(api.email.enfileirarEmail, { destinatario: funcionarioUsuario.email, destinatarioId: funcionarioUsuario._id, diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts index f0a9776..536644d 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -131,7 +131,7 @@ async function registrarNotificacoes( if (ticket.solicitanteEmail) { // Tentar usar template, senão usar envio direto try { - await ctx.runAction(api.email.enviarEmailComTemplate, { + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: ticket.solicitanteEmail, destinatarioId: ticket.solicitanteId, templateCodigo: "chamado_atualizado", @@ -145,6 +145,10 @@ async function registrarNotificacoes( }); } catch (error) { // Fallback para envio direto + console.warn( + "Erro ao agendar envio de email com template chamado_atualizado para solicitante, usando envio direto:", + error, + ); await ctx.runMutation(api.email.enfileirarEmail, { destinatario: ticket.solicitanteEmail, destinatarioId: ticket.solicitanteId, @@ -172,7 +176,7 @@ async function registrarNotificacoes( if (responsavel?.email) { // Tentar usar template, senão usar envio direto try { - await ctx.runAction(api.email.enviarEmailComTemplate, { + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: responsavel.email, destinatarioId: ticket.responsavelId, templateCodigo: "chamado_atualizado", @@ -186,6 +190,10 @@ async function registrarNotificacoes( }); } catch (error) { // Fallback para envio direto + console.warn( + "Erro ao agendar envio de email com template chamado_atualizado para responsável, usando envio direto:", + error, + ); await ctx.runMutation(api.email.enfileirarEmail, { destinatario: responsavel.email, destinatarioId: ticket.responsavelId, From 4c2d12f44383f1d1adda856e8181938e1a9087a2 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 09:50:53 -0300 Subject: [PATCH 27/31] feat: implement template filtering for notifications based on channel type and enhance email rendering with HTML wrapper, ensuring chat messages are sent as plain text --- .../(dashboard)/ti/notificacoes/+page.svelte | 47 ++++++++++- .../notificacoes/templates/[id]/+page.svelte | 17 +++- .../notificacoes/templates/novo/+page.svelte | 16 +++- packages/backend/convex/chamados.ts | 14 ++++ packages/backend/convex/email.ts | 37 +++------ packages/backend/convex/monitoramento.ts | 37 ++++++++- packages/backend/convex/templatesMensagens.ts | 79 +++++++++++++++++++ 7 files changed, 213 insertions(+), 34 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 39cef46..deacc99 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -95,6 +95,34 @@ return []; }); + function templateDisponivelParaCanal( + template: Doc<'templatesMensagens'>, + canalAtual: 'chat' | 'email' | 'ambos' + ): boolean { + const categoria = template.categoria as 'email' | 'chat' | 'ambos' | undefined; + + // Se não tiver categoria definida, considerar disponível para qualquer canal + if (!categoria) { + return true; + } + + if (canalAtual === 'ambos') { + // No modo "ambos", aceitar templates marcados para qualquer canal ou ambos + return categoria === 'chat' || categoria === 'email' || categoria === 'ambos'; + } + + // Para canal específico, aceitar templates daquele canal ou "ambos" + if (categoria === 'ambos') { + return true; + } + + return categoria === canalAtual; + } + + const templatesParaCanal = $derived.by(() => + templates.filter((t) => templateDisponivelParaCanal(t as Doc<'templatesMensagens'>, canal)) + ); + // Estados de carregamento e erro const carregandoTemplates = $derived(templatesQuery === undefined || templatesQuery === null); const carregandoUsuarios = $derived(usuariosQuery === undefined || usuariosQuery === null); @@ -211,6 +239,17 @@ return resultado; } + // Versão específica para CHAT: garante texto puro (sem HTML) + function renderizarTemplateChatLocal( + template: string, + variaveis: Record + ): string { + const textoComVariaveis = renderizarTemplate(template, variaveis); + // Remove quaisquer tags HTML que possam ter sido inseridas por engano + const textoPuro = textoComVariaveis.replace(/<[^>]*>/g, ''); + return textoPuro; + } + // Função para mostrar mensagens function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) { mensagem = { tipo, texto }; @@ -885,10 +924,10 @@ }); if (conversaId) { - // Renderizar template com variáveis do destinatário + // Renderizar template com variáveis do destinatário (chat sempre em TEXTO PURO) const mensagem = usarTemplate && templateSelecionado - ? renderizarTemplate(templateSelecionado.corpo, { + ? renderizarTemplateChatLocal(templateSelecionado.corpo, { nome: destinatario.nome, matricula: destinatario.matricula || '' }) @@ -1314,8 +1353,8 @@ {#if carregandoTemplates} - {:else if templates.length > 0} - {#each templates as template (template._id)} + {:else if templatesParaCanal.length > 0} + {#each templatesParaCanal as template (template._id)} diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte index 6430ee2..0c456fa 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte @@ -203,6 +203,13 @@ +
@@ -213,8 +220,16 @@ id="corpo" bind:value={corpo} class="textarea textarea-bordered h-40" - placeholder="Digite o conteúdo da mensagem. Você pode usar {{variavel}} para valores dinâmicos." + placeholder="Digite o conteúdo em TEXTO. Você pode usar {{variavel}} para valores dinâmicos." > +
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte index c29def9..ef98c0a 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte @@ -180,6 +180,13 @@ +
@@ -190,8 +197,15 @@ id="corpo" bind:value={corpo} class="textarea textarea-bordered h-40" - placeholder="Digite o conteúdo da mensagem. Você pode usar {{variavel}} para valores dinâmicos." + placeholder="Digite o conteúdo em TEXTO. Você pode usar {{variavel}} para valores dinâmicos." > +
diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts index 536644d..d0c5504 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -170,6 +170,20 @@ async function registrarNotificacoes( criadaEm: Date.now(), }); + // Se o ticket estiver associado a uma conversa, registrar também uma mensagem de chat + // Isso garante o "duplo canal": email + chat para notificações importantes. + if (ticket.conversaId) { + const conteudoChat = mensagem.length > 0 ? `${titulo}: ${mensagem}` : titulo; + + await ctx.db.insert("mensagens", { + conversaId: ticket.conversaId, + remetenteId: usuarioEvento, + tipo: "texto", + conteudo: conteudoChat, + enviadaEm: Date.now(), + }); + } + // Notificar responsável (se houver) if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) { const responsavel = await ctx.db.get(ticket.responsavelId); diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index b178228..dee978b 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -1,8 +1,10 @@ import { v } from "convex/values"; import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server"; import { internal, api } from "./_generated/api"; -import { renderizarTemplate } from "./templatesMensagens"; -import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper"; +import { + renderizarTemplateEmailFromDoc, + type VariaveisTemplate, +} from "./templatesMensagens"; import type { Doc, Id } from "./_generated/dataModel"; // ========== INTERNAL QUERIES ========== @@ -212,37 +214,24 @@ export const enviarEmailComTemplate = action({ } // Renderizar template com variáveis - const variaveisTemplate = args.variaveis || {}; - + const variaveisTemplate: VariaveisTemplate = args.variaveis ?? {}; + // Garantir que urlSistema sempre tenha protocolo se presente - if (variaveisTemplate.urlSistema && !variaveisTemplate.urlSistema.match(/^https?:\/\//i)) { + if ( + typeof variaveisTemplate.urlSistema === "string" && + !variaveisTemplate.urlSistema.match(/^https?:\/\//i) + ) { variaveisTemplate.urlSistema = `http://${variaveisTemplate.urlSistema}`; } - - const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate); - const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate); - // Usar htmlCorpo se disponível, senão gerar do corpo - let corpoHTML = template.htmlCorpo; - if (corpoHTML) { - // Renderizar variáveis no HTML - corpoHTML = renderizarTemplate(corpoHTML, variaveisTemplate); - } else { - // Gerar HTML do corpo renderizado - if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) { - corpoHTML = wrapEmailHTML(corpoRenderizado, tituloRenderizado); - } else { - const corpoHTMLFormatado = textToHTML(corpoRenderizado); - corpoHTML = wrapEmailHTML(corpoHTMLFormatado, tituloRenderizado); - } - } + const emailRenderizado = renderizarTemplateEmailFromDoc(template, variaveisTemplate); // Enfileirar email via mutation const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, { destinatario: args.destinatario, destinatarioId: args.destinatarioId, - assunto: tituloRenderizado, - corpo: corpoHTML, // Usar HTML completo + assunto: emailRenderizado.titulo, + corpo: emailRenderizado.html, // HTML completo com wrapper templateId: template._id, // template._id sempre existe se template não é null enviadoPor: args.enviadoPor, agendadaPara: args.agendadaPara, diff --git a/packages/backend/convex/monitoramento.ts b/packages/backend/convex/monitoramento.ts index a3bb14f..b47209b 100644 --- a/packages/backend/convex/monitoramento.ts +++ b/packages/backend/convex/monitoramento.ts @@ -363,10 +363,39 @@ export const verificarAlertasInternal = internalMutation({ } } - // TODO: Enviar email se configurado (integração com sistema de email) - // if (alerta.notifyByEmail) { - // await enviarEmailAlerta(alerta, metricValue); - // } + // Enviar email se configurado (usar template HTML padronizado) + if (alerta.notifyByEmail) { + // Buscar usuários administradores/TI para receber o alerta por email + const rolesAdminOuTi = await ctx.db + .query('roles') + .filter((q) => q.lte(q.field('nivel'), 1)) + .collect(); + + const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id)); + const usuarios = await ctx.db.query('usuarios').collect(); + const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId) && !!u.email); + + for (const usuario of usuariosTI) { + const email = usuario.email; + if (!email) continue; + + // Montar variáveis para template de alerta de sistema + const variaveisEmail = { + destinatarioNome: usuario.nome, + metricName: alerta.metricName, + metricValue: metricValue.toFixed(2), + threshold: alerta.threshold.toString() + }; + + await ctx.scheduler.runAfter(0, internal.email.enviarEmailComTemplate, { + destinatario: email, + destinatarioId: usuario._id, + templateCodigo: 'monitoramento_alerta_sistema', + variaveis: variaveisEmail, + enviadoPor: usuario._id + }); + } + } } } diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index a7fec7a..d6a0387 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -224,6 +224,71 @@ export function renderizarTemplate(template: string, variaveis: Record; + +export interface EmailRenderizado { + titulo: string; + html: string; +} + +/** + * Renderizar template para EMAIL (HTML padronizado) + * - Usa `htmlCorpo` se existir, senão gera HTML a partir de `corpo` (texto ou HTML simples) + * - Sempre aplica o wrapper visual de email + */ +export function renderizarTemplateEmailFromDoc( + template: Doc<"templatesMensagens">, + variaveis: VariaveisTemplate, +): EmailRenderizado { + const variaveisTemplate: VariaveisTemplate = { ...variaveis }; + + const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate); + + // Base para o corpo: se existir htmlCorpo usamos ele, senão usamos corpo + const baseCorpo = template.htmlCorpo ?? template.corpo ?? ""; + const corpoRenderizado = renderizarTemplate(baseCorpo, variaveisTemplate); + + let htmlFinal: string; + + if (template.htmlCorpo) { + // htmlCorpo já é HTML completo de email (com ou sem wrapper) – apenas aplica variáveis + htmlFinal = corpoRenderizado.includes("")) { + htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado); + } else { + const corpoHTML = textToHTML(corpoRenderizado); + htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado); + } + } + + return { + titulo: tituloRenderizado, + html: htmlFinal, + }; +} + +/** + * Renderizar template para CHAT (texto puro) + * - Usa sempre `corpo` como fonte + * - Remove quaisquer tags HTML residuais + */ +export function renderizarTemplateChatFromDoc( + template: Doc<"templatesMensagens">, + variaveis: VariaveisTemplate, +): string { + const corpoBase = template.corpo ?? ""; + const textoComVariaveis = renderizarTemplate(corpoBase, variaveis); + + // Garantir texto puro para o chat (sem tags HTML) + const textoPuro = textoComVariaveis.replace(/<[^>]*>/g, ""); + + return textoPuro; +} + /** * Criar templates padrão do sistema (chamado no seed) */ @@ -432,6 +497,20 @@ export const criarTemplatesPadrao = mutation({ + "
", variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"], }, + { + codigo: "monitoramento_alerta_sistema", + nome: "Alerta de Sistema (Monitoramento)", + titulo: "⚠️ Alerta de Sistema: {{metricName}}", + corpo: + "Olá {{destinatarioNome}},\n\n" + + "A métrica {{metricName}} atingiu o valor {{metricValue}} (limite configurado: {{threshold}}).\n\n" + + "Recomenda-se verificar o painel de monitoramento do SGSE para detalhes adicionais e, se necessário, " + + "executar ações corretivas.\n\n" + + "Esta é uma notificação automática do sistema de monitoramento SGSE.", + variaveis: ["destinatarioNome", "metricName", "metricValue", "threshold"], + categoria: "email" as const, + tags: ["monitoramento", "alerta", "sistema", "ti"], + }, { codigo: "ausencia_solicitada", nome: "Ausência Solicitada", From 4af566e54cc2726e14dcc256bc007a1dbd5e8d93 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 09:54:34 -0300 Subject: [PATCH 28/31] feat: add user and template counters to notifications page header for improved visibility and user engagement --- .../(dashboard)/ti/notificacoes/+page.svelte | 71 ++++++++++++------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index deacc99..ffaa5e2 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -95,6 +95,9 @@ return []; }); + const totalUsuarios = $derived(usuarios.length); + const totalTemplates = $derived(templates.length); + function templateDisponivelParaCanal( template: Doc<'templatesMensagens'>, canalAtual: 'chat' | 'email' | 'ambos' @@ -1140,34 +1143,54 @@ } -
- -
-
-
- - - +
+
+ +
+
+
+ + + +
+
+

Notificações e Mensagens

+

+ Envie avisos importantes por chat e + email HTML padronizado para os usuários do SGSE. +

+
-
-

Notificações e Mensagens

-

Enviar notificações para usuários do sistema

+
+
+
Usuários alcançáveis
+
+ {totalUsuarios} +
+
+
+
Templates cadastrados
+
+ {totalTemplates} +
+
-
- + {#if mensagem}
Date: Mon, 1 Dec 2025 11:45:27 -0300 Subject: [PATCH 29/31] feat: enhance error handling in dashboard layout and improve UI consistency across notification templates with updated styling and structure --- .../src/routes/(dashboard)/+layout.server.ts | 13 +- .../registro-pontos/+page.svelte | 1668 +++++---- .../ti/central-chamados/+page.svelte | 3231 +++++++++-------- .../(dashboard)/ti/notificacoes/+page.svelte | 40 +- .../ti/notificacoes/templates/+page.svelte | 385 +- .../notificacoes/templates/[id]/+page.svelte | 111 +- .../notificacoes/templates/novo/+page.svelte | 96 +- packages/backend/convex/monitoramento.ts | 6 +- 8 files changed, 3100 insertions(+), 2450 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/+layout.server.ts b/apps/web/src/routes/(dashboard)/+layout.server.ts index 12b79a6..f837de7 100644 --- a/apps/web/src/routes/(dashboard)/+layout.server.ts +++ b/apps/web/src/routes/(dashboard)/+layout.server.ts @@ -2,8 +2,13 @@ import { api } from "@sgse-app/backend/convex/_generated/api"; import { createConvexHttpClient } from "@mmailaender/convex-better-auth-svelte/sveltekit"; export const load = async ({ locals }) => { - const client = createConvexHttpClient({ token: locals.token }); - - const currentUser = await client.query(api.auth.getCurrentUser, {}); - return { currentUser }; + try { + const client = createConvexHttpClient({ token: locals.token }); + const currentUser = await client.query(api.auth.getCurrentUser, {}); + return { currentUser }; + } catch (error) { + console.error("Erro ao carregar usuário atual no layout do dashboard:", error); + // Evita quebrar toda a área logada em caso de falha transitória na API/auth + return { currentUser: null }; + } }; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte index a4b1d16..8663c72 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -2,7 +2,19 @@ import { onMount, onDestroy } from 'svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown, FileText } from 'lucide-svelte'; + import { + Clock, + Filter, + Download, + Printer, + BarChart3, + Users, + CheckCircle2, + XCircle, + TrendingUp, + TrendingDown, + FileText + } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarHoraPonto, getTipoRegistroLabel, formatarDataDDMMAAAA } from '$lib/utils/ponto'; import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte'; @@ -15,7 +27,7 @@ import { toast } from 'svelte-sonner'; import { Chart, registerables } from 'chart.js'; import Papa from 'papaparse'; - + Chart.register(...registerables); const client = useConvexClient(); @@ -25,7 +37,7 @@ const hoje = new Date(); const trintaDiasAtras = new Date(hoje); trintaDiasAtras.setDate(hoje.getDate() - 30); - + let dataInicio = $state(trintaDiasAtras.toISOString().split('T')[0]!); let dataFim = $state(hoje.toISOString().split('T')[0]!); let funcionarioIdFiltro = $state | ''>(''); @@ -41,14 +53,16 @@ // Parâmetros reativos para queries const registrosParams = $derived({ - funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined, + funcionarioId: + funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined, dataInicio, - dataFim, + dataFim }); const estatisticasParams = $derived({ dataInicio, dataFim, - funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined, + funcionarioId: + funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined }); // Queries @@ -77,11 +91,10 @@ } }); - // Dados do gráfico baseados nas estatísticas const chartData = $derived.by(() => { if (!estatisticas) return null; - + return { labels: ['Estatísticas de Registros'], datasets: [ @@ -90,14 +103,14 @@ data: [estatisticas.dentroDoPrazo || 0], backgroundColor: 'rgba(34, 197, 94, 0.8)', borderColor: 'rgba(34, 197, 94, 1)', - borderWidth: 2, + borderWidth: 2 }, { label: 'Fora do Prazo', data: [estatisticas.foraDoPrazo || 0], backgroundColor: 'rgba(239, 68, 68, 0.8)', borderColor: 'rgba(239, 68, 68, 1)', - borderWidth: 2, + borderWidth: 2 } ] }; @@ -135,10 +148,10 @@ color: 'hsl(var(--bc))', font: { size: 12, - family: "'Inter', sans-serif", + family: "'Inter', sans-serif" }, usePointStyle: true, - padding: 15, + padding: 15 } }, tooltip: { @@ -149,7 +162,7 @@ borderWidth: 1, padding: 12, callbacks: { - label: function(context) { + label: function (context) { const label = context.dataset.label || ''; const value = context.parsed.y; const total = estatisticas.totalRegistros; @@ -163,12 +176,12 @@ x: { stacked: true, grid: { - display: false, + display: false }, ticks: { color: 'hsl(var(--bc))', font: { - size: 12, + size: 12 } } }, @@ -176,14 +189,14 @@ stacked: true, beginAtZero: true, grid: { - color: 'rgba(0, 0, 0, 0.05)', + color: 'rgba(0, 0, 0, 0.05)' }, ticks: { color: 'hsl(var(--bc))', font: { - size: 11, + size: 11 }, - stepSize: 1, + stepSize: 1 } } }, @@ -193,7 +206,7 @@ }, interaction: { mode: 'index', - intersect: false, + intersect: false } } }); @@ -210,7 +223,7 @@ chartInstance.destroy(); chartInstance = null; } - + // Aguardar um pouco para garantir que o canvas está renderizado const timeoutId = setTimeout(() => { try { @@ -255,21 +268,21 @@ // Os filtros de status e localização são aplicados aqui no frontend const registrosFiltrados = $derived.by(() => { if (!registros || registros.length === 0) return []; - + let resultado = [...registros]; - + // Filtro de status (Dentro/Fora do Prazo) if (statusFiltro !== 'todos') { - resultado = resultado.filter(r => { + resultado = resultado.filter((r) => { if (statusFiltro === 'dentro') return r.dentroDoPrazo === true; if (statusFiltro === 'fora') return r.dentroDoPrazo === false; return true; }); } - + // Filtro de localização (Dentro/Fora do Raio) if (localizacaoFiltro !== 'todos') { - resultado = resultado.filter(r => { + resultado = resultado.filter((r) => { // Se não houver informação de localização, excluir do resultado quando filtro está ativo if (r.dentroRaioPermitido === undefined || r.dentroRaioPermitido === null) { return false; @@ -279,7 +292,7 @@ return true; }); } - + return resultado; }); @@ -295,9 +308,18 @@ string, { data: string; - registros: Array; - saldoDiario?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }; - saldoDiarioComparativo?: { trabalhadoMinutos: number; esperadoMinutos: number; diferencaMinutos: number }; + registros: Array<(typeof registros)[number]>; + saldoDiario?: { + saldoMinutos: number; + horas: number; + minutos: number; + positivo: boolean; + }; + saldoDiarioComparativo?: { + trabalhadoMinutos: number; + esperadoMinutos: number; + diferencaMinutos: number; + }; } >; } @@ -308,7 +330,7 @@ // Usar registros filtrados ao invés de registros originais const registrosParaAgrupar = registrosFiltrados; - + // Verificar se registros é um array válido if (!Array.isArray(registrosParaAgrupar) || registrosParaAgrupar.length === 0) { return []; @@ -333,7 +355,7 @@ agrupados[key] = { funcionario: registro.funcionario, funcionarioId: registro.funcionarioId, - registrosPorData: {}, + registrosPorData: {} }; } @@ -342,10 +364,10 @@ agrupados[key]!.registrosPorData[dataKey] = { data: dataKey, registros: [], - saldoDiario: undefined, + saldoDiario: undefined }; } - + // Verificar se o registro já não está no array antes de adicionar const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some( (r) => r._id === registro._id @@ -357,7 +379,7 @@ // Ordenar registros por data e hora dentro de cada grupo e usar saldo diário da query const resultado = Object.values(agrupados); - + // Ordenar grupos por nome do funcionário resultado.sort((a, b) => { const nomeA = a.funcionario?.nome || ''; @@ -372,7 +394,7 @@ }); // Criar novo objeto com datas ordenadas - const registrosPorDataOrdenado: Record = {}; + const registrosPorDataOrdenado: Record = {}; for (const dataKey of datasOrdenadas) { registrosPorDataOrdenado[dataKey] = grupo.registrosPorData[dataKey]!; } @@ -393,32 +415,52 @@ if (configData) { // Calcular saldos parciais (Par 1, Par 2, etc.) const saldosParciais = calcularSaldosParciais(grupoData.registros); - + // Somar todos os saldos parciais para obter o total trabalhado let totalTrabalhado = 0; for (const saldoParcial of saldosParciais.values()) { totalTrabalhado += saldoParcial.saldoMinutos; } - + // Calcular carga horária diária total esperada (soma dos dois pares) - const [horaEntradaConfig, minutoEntradaConfig] = configData.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = configData.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] = configData.horarioRetornoAlmoco.split(':').map(Number); - const [horaSaidaConfig, minutoSaidaConfig] = configData.horarioSaida.split(':').map(Number); - + const [horaEntradaConfig, minutoEntradaConfig] = configData.horarioEntrada + .split(':') + .map(Number); + const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = configData.horarioSaidaAlmoco + .split(':') + .map(Number); + const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] = + configData.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaidaConfig, minutoSaidaConfig] = configData.horarioSaida + .split(':') + .map(Number); + // Par 1: entrada -> saida_almoco - const minutosPar1EsperadoConfig = (horaSaidaAlmocoConfig * 60 + minutoSaidaAlmocoConfig) - (horaEntradaConfig * 60 + minutoEntradaConfig); - const minutosPar1EsperadoAjustadoConfig = minutosPar1EsperadoConfig < 0 ? minutosPar1EsperadoConfig + 24 * 60 : minutosPar1EsperadoConfig; - + const minutosPar1EsperadoConfig = + horaSaidaAlmocoConfig * 60 + + minutoSaidaAlmocoConfig - + (horaEntradaConfig * 60 + minutoEntradaConfig); + const minutosPar1EsperadoAjustadoConfig = + minutosPar1EsperadoConfig < 0 + ? minutosPar1EsperadoConfig + 24 * 60 + : minutosPar1EsperadoConfig; + // Par 2: retorno_almoco -> saida - const minutosPar2EsperadoConfig = (horaSaidaConfig * 60 + minutoSaidaConfig) - (horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig); - const minutosPar2EsperadoAjustadoConfig = minutosPar2EsperadoConfig < 0 ? minutosPar2EsperadoConfig + 24 * 60 : minutosPar2EsperadoConfig; - - const cargaHorariaDiariaEsperadaMinutos = minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig; - + const minutosPar2EsperadoConfig = + horaSaidaConfig * 60 + + minutoSaidaConfig - + (horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig); + const minutosPar2EsperadoAjustadoConfig = + minutosPar2EsperadoConfig < 0 + ? minutosPar2EsperadoConfig + 24 * 60 + : minutosPar2EsperadoConfig; + + const cargaHorariaDiariaEsperadaMinutos = + minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig; + // Calcular diferença em relação à carga horária diária total configurada const diferencaMinutos = totalTrabalhado - cargaHorariaDiariaEsperadaMinutos; - + // Armazenar saldo comparativo grupoData.saldoDiarioComparativo = { trabalhadoMinutos: totalTrabalhado, @@ -427,11 +469,15 @@ }; } else { // Fallback: usar cálculo simples se não houver configuração - const primeiroRegistro = grupoData.registros[0]; - if (primeiroRegistro && 'saldoDiario' in primeiroRegistro && primeiroRegistro.saldoDiario) { - grupoData.saldoDiario = primeiroRegistro.saldoDiario; - } else { - grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros); + const primeiroRegistro = grupoData.registros[0]; + if ( + primeiroRegistro && + 'saldoDiario' in primeiroRegistro && + primeiroRegistro.saldoDiario + ) { + grupoData.saldoDiario = primeiroRegistro.saldoDiario; + } else { + grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros); } } } @@ -440,10 +486,10 @@ // Filtrar grupos que não têm registros após aplicar os filtros // Isso garante que apenas funcionários com registros que passam pelos filtros sejam exibidos - const resultadoFiltrado = resultado.filter(grupo => { + const resultadoFiltrado = resultado.filter((grupo) => { // Verificar se o grupo tem pelo menos um registro em alguma data const temRegistros = Object.values(grupo.registrosPorData).some( - grupoData => grupoData.registros && grupoData.registros.length > 0 + (grupoData) => grupoData.registros && grupoData.registros.length > 0 ); return temRegistros; }); @@ -465,7 +511,12 @@ } // Função para formatar saldo diário - function formatarSaldoDiario(saldo?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }): string { + function formatarSaldoDiario(saldo?: { + saldoMinutos: number; + horas: number; + minutos: number; + positivo: boolean; + }): string { if (!saldo) return '-'; const sinal = saldo.positivo ? '+' : '-'; return `${sinal}${saldo.horas}h ${saldo.minutos}min`; @@ -476,7 +527,7 @@ // Obter nome do funcionário selecionado const funcionarioSelecionadoNome = $derived.by(() => { if (!funcionarioIdFiltro) return null; - return funcionarios.find(f => f._id === funcionarioIdFiltro)?.nome || null; + return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null; }); // Função para calcular saldo diário como diferença entre saída e entrada @@ -484,13 +535,21 @@ * Calcula saldos parciais entre cada par entrada/saída * Retorna um mapa com o índice do registro e seu saldo parcial */ - function calcularSaldosParciais(registros: Array<{ tipo: string; hora: number; minuto: number; _id?: any }>): Map { - const saldos = new Map(); + function calcularSaldosParciais( + registros: Array<{ tipo: string; hora: number; minuto: number; _id?: any }> + ): Map< + number, + { saldoMinutos: number; horas: number; minutos: number; positivo: boolean; parNumero: number } + > { + const saldos = new Map< + number, + { saldoMinutos: number; horas: number; minutos: number; positivo: boolean; parNumero: number } + >(); if (registros.length === 0) return saldos; // Criar array com índices originais const registrosComIndice = registros.map((r, idx) => ({ ...r, originalIndex: idx })); - + // Ordenar registros por hora e minuto para processar em ordem cronológica const registrosOrdenados = [...registrosComIndice].sort((a, b) => { if (a.hora !== b.hora) { @@ -502,12 +561,12 @@ // Identificar pares entrada/saída // Par 1: entrada -> saida_almoco // Par 2: retorno_almoco -> saida - let entradaAtual: typeof registrosComIndice[0] | null = null; + let entradaAtual: (typeof registrosComIndice)[0] | null = null; let parNumero = 1; for (let i = 0; i < registrosOrdenados.length; i++) { const registro = registrosOrdenados[i]; - + // Considerar entrada ou retorno_almoco como início de um período if (registro.tipo === 'entrada' || registro.tipo === 'retorno_almoco') { entradaAtual = registro; @@ -517,7 +576,7 @@ // Calcular diferença entre saída e entrada const minutosEntrada = entradaAtual.hora * 60 + entradaAtual.minuto; const minutosSaida = registro.hora * 60 + registro.minuto; - + let saldoMinutos = minutosSaida - minutosEntrada; if (saldoMinutos < 0) { saldoMinutos += 24 * 60; // Adicionar um dia em minutos @@ -544,7 +603,9 @@ return saldos; } - function calcularSaldoDiario(registros: Array<{ tipo: string; hora: number; minuto: number }>): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null { + function calcularSaldoDiario( + registros: Array<{ tipo: string; hora: number; minuto: number }> + ): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null { if (registros.length === 0) return null; // Ordenar registros por hora e minuto @@ -565,7 +626,7 @@ // Calcular diferença em minutos const minutosEntrada = entrada.hora * 60 + entrada.minuto; const minutosSaida = saida.hora * 60 + saida.minuto; - + // Se a saída for no dia seguinte (após meia-noite), adicionar 24 horas let saldoMinutos = minutosSaida - minutosEntrada; if (saldoMinutos < 0) { @@ -579,7 +640,7 @@ saldoMinutos, horas, minutos, - positivo: true, // Sempre positivo, pois é tempo trabalhado + positivo: true // Sempre positivo, pois é tempo trabalhado }; } @@ -587,9 +648,17 @@ * Calcula saldos por par entrada/saída * Retorna um mapa com o índice do registro e informações do saldo do par */ - function calcularSaldosPorPar(registros: Array<{ tipo: string; hora: number; minuto: number }>): Map { - const saldos = new Map(); - + function calcularSaldosPorPar( + registros: Array<{ tipo: string; hora: number; minuto: number }> + ): Map< + number, + { saldoMinutos: number; horas: number; minutos: number; parIndex: number; tamanhoPar: number } + > { + const saldos = new Map< + number, + { saldoMinutos: number; horas: number; minutos: number; parIndex: number; tamanhoPar: number } + >(); + if (registros.length === 0) return saldos; // Ordenar registros por hora e minuto @@ -606,7 +675,7 @@ for (let i = 0; i < registrosOrdenados.length; i++) { const reg = registrosOrdenados[i]; - + // Identificar início de um par (entrada ou retorno_almoco) if (reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') { // Se havia um par anterior incompleto, limpar @@ -619,11 +688,11 @@ // Identificar fim de um par (saida_almoco ou saida) else if ((reg.tipo === 'saida_almoco' || reg.tipo === 'saida') && entradaAtual) { indicesPar.push(i); - + // Calcular saldo do par (saída - entrada) const minutosEntrada = entradaAtual.hora * 60 + entradaAtual.minuto; const minutosSaida = reg.hora * 60 + reg.minuto; - + let saldoMinutos = minutosSaida - minutosEntrada; if (saldoMinutos < 0) { saldoMinutos += 24 * 60; // Adicionar um dia em minutos @@ -665,20 +734,9 @@ horarioRetornoAlmoco: string; horarioSaida: string; } - ): Map { - const saldos = new Map(); + } + > { + const saldos = new Map< + number, + { + trabalhadoMinutos: number; + trabalhadoHoras: number; + trabalhadoMinutosResto: number; + esperadoMinutos: number; + esperadoHoras: number; + esperadoMinutosResto: number; + diferencaMinutos: number; + diferencaHoras: number; + diferencaMinutosResto: number; + parIndex: number; + tamanhoPar: number; + } + >(); if (registros.length === 0) return saldos; // Parsear horários esperados da configuração - const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada + .split(':') + .map(Number); + const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco + .split(':') + .map(Number); + const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco + .split(':') + .map(Number); const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number); // Ordenar registros por hora e minuto @@ -748,7 +829,8 @@ } } else { // Par 2: retorno_almoco -> saida - const minutosEntradaEsperada = horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; + const minutosEntradaEsperada = + horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; const minutosSaidaEsperada = horaSaidaEsperada * 60 + minutoSaidaEsperado; esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada; if (esperadoMinutos < 0) { @@ -802,33 +884,38 @@ const dias: string[] = []; const inicio = new Date(dataInicio); const fim = new Date(dataFim); - + for (let d = new Date(inicio); d <= fim; d.setDate(d.getDate() + 1)) { dias.push(d.toISOString().split('T')[0]!); } - + return dias; } /** * Gera registros esperados para um dia baseado na configuração */ - function gerarRegistrosEsperados(data: string, config: { - horarioEntrada: string; - horarioSaidaAlmoco: string; - horarioRetornoAlmoco: string; - horarioSaida: string; - }): Array<{ tipo: string; hora: number; minuto: number; data: string }> { + function gerarRegistrosEsperados( + data: string, + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + } + ): Array<{ tipo: string; hora: number; minuto: number; data: string }> { const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number); const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco + .split(':') + .map(Number); const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number); return [ { tipo: 'entrada', hora: horaEntrada, minuto: minutoEntrada, data }, { tipo: 'saida_almoco', hora: horaSaidaAlmoco, minuto: minutoSaidaAlmoco, data }, { tipo: 'retorno_almoco', hora: horaRetornoAlmoco, minuto: minutoRetornoAlmoco, data }, - { tipo: 'saida', hora: horaSaida, minuto: minutoSaida, data }, + { tipo: 'saida', hora: horaSaida, minuto: minutoSaida, data } ]; } @@ -839,9 +926,7 @@ registroEsperado: { tipo: string; hora: number; minuto: number }, registrosReais: Array<{ tipo: string; hora: number; minuto: number }> ): boolean { - return registrosReais.some( - (r) => r.tipo === registroEsperado.tipo - ); + return registrosReais.some((r) => r.tipo === registroEsperado.tipo); } function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) { @@ -854,7 +939,7 @@ const hoje = new Date(); const trintaDiasAtras = new Date(hoje); trintaDiasAtras.setDate(hoje.getDate() - 30); - + dataInicio = trintaDiasAtras.toISOString().split('T')[0]!; dataFim = hoje.toISOString().split('T')[0]!; funcionarioIdFiltro = ''; @@ -867,7 +952,7 @@ async function exportarCSV() { try { const registrosParaExportar = registrosFiltrados; - + if (!registrosParaExportar || registrosParaExportar.length === 0) { toast.error('Nenhum registro para exportar'); return; @@ -882,28 +967,29 @@ nomeEntrada: config.nomeEntrada, nomeSaidaAlmoco: config.nomeSaidaAlmoco, nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, + nomeSaida: config.nomeSaida }) : getTipoRegistroLabel(registro.tipo); - + const horario = formatarHoraPonto(registro.hora, registro.minuto); const status = registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'; - const localizacao = registro.dentroRaioPermitido === true - ? 'Dentro do Raio' - : registro.dentroRaioPermitido === false - ? 'Fora do Raio' - : 'Não Validado'; - + const localizacao = + registro.dentroRaioPermitido === true + ? 'Dentro do Raio' + : registro.dentroRaioPermitido === false + ? 'Fora do Raio' + : 'Não Validado'; + return { - 'Data': formatarDataDDMMAAAA(registro.data), - 'Funcionário': funcionarioNome, - 'Matrícula': funcionarioMatricula, - 'Tipo': tipo, - 'Horário': horario, - 'Status': status, - 'Localização': localizacao, - 'IP': registro.ipAddress || 'N/A', - 'Dispositivo': registro.deviceType || 'N/A', + Data: formatarDataDDMMAAAA(registro.data), + Funcionário: funcionarioNome, + Matrícula: funcionarioMatricula, + Tipo: tipo, + Horário: horario, + Status: status, + Localização: localizacao, + IP: registro.ipAddress || 'N/A', + Dispositivo: registro.deviceType || 'N/A' }; }); @@ -911,7 +997,7 @@ const csv = Papa.unparse(csvData, { header: true, delimiter: ';', - encoding: 'UTF-8', + encoding: 'UTF-8' }); // Adicionar BOM para Excel reconhecer UTF-8 corretamente @@ -951,7 +1037,7 @@ if (!funcionarioParaImprimir) return; const funcionarioId = funcionarioParaImprimir; - + // Verificar se pelo menos uma seção foi selecionada if (!Object.values(sections).some((v) => v)) { toast.error('Selecione pelo menos uma seção para imprimir'); @@ -968,7 +1054,7 @@ const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, { funcionarioId, dataInicio, - dataFim, + dataFim }); if (!registrosFuncionario || registrosFuncionario.length === 0) { @@ -1053,7 +1139,7 @@ motivoTipo?: string; observacoes?: string; }> = []; - + let dispensas: Array<{ dataInicio: string; dataFim: string; @@ -1067,14 +1153,15 @@ if (sections.alteracoesGestor) { try { - const todasHomologacoes = await client.query(api.pontos.listarHomologacoes, { - funcionarioId, - }) || []; - + const todasHomologacoes = + (await client.query(api.pontos.listarHomologacoes, { + funcionarioId + })) || []; + // Filtrar homologações pelo período selecionado const dataInicioTimestamp = new Date(dataInicio + 'T00:00:00').getTime(); const dataFimTimestamp = new Date(dataFim + 'T23:59:59').getTime(); - + homologacoes = todasHomologacoes.filter((h) => { return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp; }); @@ -1086,19 +1173,20 @@ if (sections.dispensasRegistro) { try { - const todasDispensas = await client.query(api.pontos.listarDispensas, { - funcionarioId, - apenasAtivas: false, - }) || []; - + const todasDispensas = + (await client.query(api.pontos.listarDispensas, { + funcionarioId, + apenasAtivas: false + })) || []; + // Filtrar dispensas que têm interseção com o período selecionado const dataInicioPeriodo = new Date(dataInicio + 'T00:00:00'); const dataFimPeriodo = new Date(dataFim + 'T23:59:59'); - + dispensas = todasDispensas.filter((d) => { const dispensaInicio = new Date(d.dataInicio + 'T00:00:00'); const dispensaFim = new Date(d.dataFim + 'T23:59:59'); - + // Verificar se há interseção entre os períodos return dispensaInicio <= dataFimPeriodo && dispensaFim >= dataInicioPeriodo; }); @@ -1109,11 +1197,14 @@ } // Variável para armazenar saldos diários (usada no resumo do banco de horas) - const saldosDiariosPorData: Record = {}; + const saldosDiariosPorData: Record< + string, + { + diferencaMinutos: number; + trabalhadoMinutos: number; + esperadoMinutos: number; + } + > = {}; // Tabela de registros if (sections.registrosPonto) { @@ -1160,7 +1251,7 @@ acelerometroZ: r.acelerometroZ, movimentoDetectado: r.movimentoDetectado, magnitudeMovimento: r.magnitudeMovimento, - sensorDisponivel: r.sensorDisponivel, + sensorDisponivel: r.sensorDisponivel }); } @@ -1189,7 +1280,7 @@ hora: reg.hora, minuto: reg.minuto, real: true, - dentroDoPrazo: reg.dentroDoPrazo, + dentroDoPrazo: reg.dentroDoPrazo }); } @@ -1200,7 +1291,7 @@ tipo: regEsperado.tipo, hora: regEsperado.hora, minuto: regEsperado.minuto, - real: false, + real: false }); } } @@ -1218,34 +1309,40 @@ if (a.hora !== b.hora) return a.hora - b.hora; return a.minuto - b.minuto; }); - const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config); + const saldosComparativosPorPar = calcularSaldoComparativoPorPar( + regsReaisOrdenados, + config + ); // Calcular saldos esperados para pares incompletos ou dias sem registros - const saldosEsperadosPorPar: Map = new Map(); - + const saldosEsperadosPorPar: Map< + number, + { + trabalhadoMinutos: number; + trabalhadoHoras: number; + trabalhadoMinutosResto: number; + esperadoMinutos: number; + esperadoHoras: number; + esperadoMinutosResto: number; + diferencaMinutos: number; + diferencaHoras: number; + diferencaMinutosResto: number; + tamanhoPar: number; + incompleto: boolean; + } + > = new Map(); + // Calcular saldo diário total (diferença acumulada de todos os pares) // Declarar variáveis ANTES de usá-las let saldoDiarioTotalDiferencaMinutos = 0; let saldoDiarioTotalTrabalhadoMinutos = 0; let saldoDiarioTotalEsperadoMinutos = 0; - + // Criar conjunto de chaves de registros que já foram processados como completos // Isso será usado tanto para evitar criar pares incompletos quanto para evitar somar duplicados const chavesProcessadasCompletas = new Set(); const paresCompletosProcessados = new Set(); // Rastrear parIndex já processados - + // Criar conjunto de chaves e somar saldos dos pares completos // IMPORTANTE: saldosComparativosPorPar associa o saldo a AMBOS os registros do par (entrada + saída) // Então precisamos garantir que somamos apenas UMA VEZ por par, não uma vez por registro @@ -1260,7 +1357,7 @@ saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos; paresCompletosProcessados.add(saldo.parIndex); } - + // Adicionar chaves para evitar processamento duplicado depois const chaveEntrada = `${regEntrada.tipo}-${regEntrada.hora}-${regEntrada.minuto}`; chavesProcessadasCompletas.add(chaveEntrada); @@ -1276,12 +1373,12 @@ } } } - + // Identificar pares incompletos e calcular saldos esperados // IMPORTANTE: Só processar pares que NÃO foram processados como completos for (let i = 0; i < todosRegistros.length; i++) { const reg = todosRegistros[i]; - + // Se é entrada ou retorno_almoco (início de par) e é real if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && reg.real) { // Verificar se este registro já foi processado como par completo @@ -1289,11 +1386,11 @@ if (chavesProcessadasCompletas.has(chaveRegistro)) { continue; // Já foi processado como par completo, pular } - + // Verificar se há saída correspondente real const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida'; - const saidaEncontrada = regsReais.find(r => r.tipo === tipoSaidaEsperado); - + const saidaEncontrada = regsReais.find((r) => r.tipo === tipoSaidaEsperado); + // Se há saída correspondente, verificar se ela também está em chavesProcessadasCompletas // Se estiver, significa que este par já foi processado como completo if (saidaEncontrada) { @@ -1307,7 +1404,7 @@ // Par incompleto: entrada real sem saída correspondente // NÃO calcular tempo trabalhado aqui porque não há saída marcada // O tempo trabalhado será 0, e a diferença será negativa (0 - esperado) - const regEsperado = regsEsperados.find(r => r.tipo === tipoSaidaEsperado); + const regEsperado = regsEsperados.find((r) => r.tipo === tipoSaidaEsperado); if (regEsperado) { // Tempo trabalhado = 0 (não há saída marcada, então não podemos assumir tempo trabalhado) const trabalhadoMinutos = 0; @@ -1315,16 +1412,24 @@ // Calcular tempo esperado let esperadoMinutos: number; if (reg.tipo === 'entrada') { - const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco.split(':').map(Number); + const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada + .split(':') + .map(Number); + const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = + config.horarioSaidaAlmoco.split(':').map(Number); const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado; - const minutosSaidaEsperadaConfig = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado; + const minutosSaidaEsperadaConfig = + horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado; esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada; if (esperadoMinutos < 0) esperadoMinutos += 24 * 60; } else { - const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco.split(':').map(Number); - const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number); - const minutosEntradaEsperada = horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; + const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = + config.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida + .split(':') + .map(Number); + const minutosEntradaEsperada = + horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado; esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada; if (esperadoMinutos < 0) esperadoMinutos += 24 * 60; @@ -1342,9 +1447,13 @@ // Contar quantos registros fazem parte deste par na lista todosRegistros const indexNaListaTodos = todosRegistros.findIndex( - (r, idx) => idx >= i && r.tipo === reg.tipo && r.hora === reg.hora && r.minuto === reg.minuto + (r, idx) => + idx >= i && + r.tipo === reg.tipo && + r.hora === reg.hora && + r.minuto === reg.minuto ); - + // Contar registros do par (entrada + saída esperada) let tamanhoPar = 1; // entrada const saidaEsperadaNaLista = todosRegistros.find( @@ -1378,7 +1487,7 @@ if (regsReais.length > 0) { for (let i = 0; i < todosRegistros.length; i++) { const reg = todosRegistros[i]; - + // Se é entrada ou retorno_almoco (início de par) e NÃO é real if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) { // Verificar se a saída correspondente também não foi marcada @@ -1392,16 +1501,24 @@ // Tempo trabalhado = 0, tempo esperado = configurado, diferença = -esperado let esperadoMinutos: number; if (reg.tipo === 'entrada') { - const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco.split(':').map(Number); + const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada + .split(':') + .map(Number); + const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = + config.horarioSaidaAlmoco.split(':').map(Number); const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado; - const minutosSaidaEsperadaConfig = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado; + const minutosSaidaEsperadaConfig = + horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado; esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada; if (esperadoMinutos < 0) esperadoMinutos += 24 * 60; } else { - const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = config.horarioRetornoAlmoco.split(':').map(Number); - const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number); - const minutosEntradaEsperada = horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; + const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] = + config.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida + .split(':') + .map(Number); + const minutosEntradaEsperada = + horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado; esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada; if (esperadoMinutos < 0) esperadoMinutos += 24 * 60; @@ -1450,7 +1567,7 @@ // Usar o conjunto chavesProcessadasCompletas criado anteriormente for (const [indexNaListaTodos, saldo] of saldosEsperadosPorPar.entries()) { const regNaListaTodos = todosRegistros[indexNaListaTodos]; - + // Verificar se este registro real já foi processado como par completo if (regNaListaTodos && regNaListaTodos.real) { const chaveRegistro = `${regNaListaTodos.tipo}-${regNaListaTodos.hora}-${regNaListaTodos.minuto}`; @@ -1458,21 +1575,22 @@ // Este registro está em chavesProcessadasCompletas // Verificar se há uma saída correspondente que também está lá // Se ambas estão, significa que o par completo já foi processado - const tipoSaidaEsperado = regNaListaTodos.tipo === 'entrada' ? 'saida_almoco' : 'saida'; - + const tipoSaidaEsperado = + regNaListaTodos.tipo === 'entrada' ? 'saida_almoco' : 'saida'; + // Procurar saída correspondente em regsReais que também está em chavesProcessadasCompletas - const saidaCompletaEncontrada = regsReais.find(r => { + const saidaCompletaEncontrada = regsReais.find((r) => { if (r.tipo !== tipoSaidaEsperado) return false; const chaveSaida = `${r.tipo}-${r.hora}-${r.minuto}`; return chavesProcessadasCompletas.has(chaveSaida); }); - + if (saidaCompletaEncontrada) { continue; // Par completo já processado (entrada + saída estão em chavesProcessadasCompletas), não somar novamente } } } - + if (saldo.trabalhadoMinutos === 0 && !saldo.incompleto) { // Par completamente não marcado: adicionar diferença negativa saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos; @@ -1486,13 +1604,17 @@ saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos; } } - + // Se não há registros reais, calcular saldo esperado baseado na configuração if (regsReais.length === 0) { // Calcular saldo esperado do dia completo const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco + .split(':') + .map(Number); + const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco + .split(':') + .map(Number); const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number); // Par 1: entrada -> saida_almoco @@ -1511,10 +1633,11 @@ saldoDiarioTotalEsperadoMinutos = saldoPar1 + saldoPar2; saldoDiarioTotalDiferencaMinutos = -saldoDiarioTotalEsperadoMinutos; // Diferença negativa (0 - esperado) } - + // Calcular diferença corretamente: trabalhado - esperado (não somar diferenças dos pares) - const diferencaDiariaCorrigida = saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos; - + const diferencaDiariaCorrigida = + saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos; + // Armazenar saldo diário completo (usado no resumo do banco de horas) saldosDiariosPorData[data] = { diferencaMinutos: diferencaDiariaCorrigida, @@ -1524,21 +1647,30 @@ // Calcular carga horária diária total esperada para inicializar saldo acumulado const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco + .split(':') + .map(Number); + const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco + .split(':') + .map(Number); const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number); - const minutosPar1Esperado = (horaSaidaAlmoco * 60 + minutoSaidaAlmoco) - (horaEntrada * 60 + minutoEntrada); - const minutosPar1EsperadoAjustado = minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado; + const minutosPar1Esperado = + horaSaidaAlmoco * 60 + minutoSaidaAlmoco - (horaEntrada * 60 + minutoEntrada); + const minutosPar1EsperadoAjustado = + minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado; - const minutosPar2Esperado = (horaSaida * 60 + minutoSaida) - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco); - const minutosPar2EsperadoAjustado = minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado; + const minutosPar2Esperado = + horaSaida * 60 + minutoSaida - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco); + const minutosPar2EsperadoAjustado = + minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado; + + const cargaHorariaDiariaTotalMinutos = + minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado; - const cargaHorariaDiariaTotalMinutos = minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado; - // Inicializar saldo diário acumulado com a carga horária total diária let saldoDiarioAcumuladoMinutos = cargaHorariaDiariaTotalMinutos; - + // Rastrear quais pares já foram processados para evitar decrementar múltiplas vezes // Usar string como chave: "tipo-parIndex" ou "tipo-indice" para pares incompletos const paresProcessadosParaSaldo = new Set(); @@ -1549,14 +1681,19 @@ const linha: any[] = [ dataFormatada, config - ? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', { - nomeEntrada: config.nomeEntrada, - nomeSaidaAlmoco: config.nomeSaidaAlmoco, - nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, - }) - : getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'), - formatarHoraPonto(reg.hora, reg.minuto), + ? getTipoRegistroLabel( + reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', + { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida + } + ) + : getTipoRegistroLabel( + reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' + ), + formatarHoraPonto(reg.hora, reg.minuto) ]; // Marcar linha como não marcada para aplicar cor vermelha depois @@ -1567,7 +1704,7 @@ // Saldo Diário por par entrada/saída com cálculo comparativo if (sections.saldoDiario) { const isInicioPar = reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco'; - + if (reg.real && isInicioPar) { const indexReal = regsReaisOrdenados.findIndex( (r) => r.tipo === reg.tipo && r.hora === reg.hora && r.minuto === reg.minuto @@ -1577,30 +1714,32 @@ const saldoEsperado = saldosEsperadosPorPar.get(i); if (saldoEsperado) { // Par incompleto: decrementar saldo acumulado - + // Decrementar saldo acumulado apenas uma vez por par const chavePar = `${reg.tipo}-incompleto-${i}`; if (!paresProcessadosParaSaldo.has(chavePar)) { saldoDiarioAcumuladoMinutos -= saldoEsperado.trabalhadoMinutos; paresProcessadosParaSaldo.add(chavePar); } - + // Calcular saldo acumulado formatado // Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo) // Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo) - const saldoAcumuladoHoras = Math.floor(Math.abs(saldoDiarioAcumuladoMinutos) / 60); + const saldoAcumuladoHoras = Math.floor( + Math.abs(saldoDiarioAcumuladoMinutos) / 60 + ); const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60; // Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-'; const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0; - + // Marcar linha para aplicar cor no saldo if (trabalhouMaisQueEsperado) { linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado } else { linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar } - + linha.push({ content: `${saldoEsperado.trabalhadoHoras}h ${saldoEsperado.trabalhadoMinutosResto}min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, rowSpan: saldoEsperado.tamanhoPar @@ -1614,86 +1753,91 @@ saldoDiarioAcumuladoMinutos -= saldoPar.trabalhadoMinutos; paresProcessadosParaSaldo.add(chavePar); } - + // Calcular saldo acumulado formatado // Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo) // Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo) - const saldoAcumuladoHoras = Math.floor(Math.abs(saldoDiarioAcumuladoMinutos) / 60); + const saldoAcumuladoHoras = Math.floor( + Math.abs(saldoDiarioAcumuladoMinutos) / 60 + ); const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60; // Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-'; const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0; - + // Marcar linha para aplicar cor no saldo if (trabalhouMaisQueEsperado) { linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado } else { linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar } - + linha.push({ content: `${saldoPar.trabalhadoHoras}h ${saldoPar.trabalhadoMinutosResto}min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, rowSpan: saldoPar.tamanhoPar }); - } else { - linha.push('-'); - } + } else { + linha.push('-'); + } } else { linha.push('-'); } } else if (reg.real) { // Saída real: não adicionar (já coberto pelo rowspan da entrada) // Mas verificar se precisa adicionar '-' se não houver par completo - const tipoEntradaEsperado = reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco'; - const entradaReal = regsReais.find(r => r.tipo === tipoEntradaEsperado); + const tipoEntradaEsperado = + reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco'; + const entradaReal = regsReais.find((r) => r.tipo === tipoEntradaEsperado); if (!entradaReal) { linha.push('-'); } } else { // Registro não marcado: verificar se faz parte de um par incompleto ou dia sem registros const isInicioPar = reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco'; - + if (isInicioPar) { // Verificar se há saldo esperado para este par const saldoEsperado = saldosEsperadosPorPar.get(i); if (saldoEsperado) { // Par incompleto ou completamente não marcado: decrementar saldo acumulado - + // Decrementar saldo acumulado apenas uma vez por par const chavePar = `${reg.tipo}-esperado-${i}`; if (!paresProcessadosParaSaldo.has(chavePar)) { // Se não há tempo trabalhado, decrementar o tempo esperado completo if (saldoEsperado.trabalhadoMinutos === 0) { saldoDiarioAcumuladoMinutos -= saldoEsperado.esperadoMinutos; - } else { + } else { saldoDiarioAcumuladoMinutos -= saldoEsperado.trabalhadoMinutos; - } + } paresProcessadosParaSaldo.add(chavePar); } - + // Calcular saldo acumulado formatado // Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo) // Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo) - const saldoAcumuladoHoras = Math.floor(Math.abs(saldoDiarioAcumuladoMinutos) / 60); + const saldoAcumuladoHoras = Math.floor( + Math.abs(saldoDiarioAcumuladoMinutos) / 60 + ); const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60; // Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-'; const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0; - + // Marcar linha para aplicar cor no saldo if (trabalhouMaisQueEsperado) { linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado - } else { + } else { linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar } - + // Se par completamente não marcado (trabalhado = 0), mostrar apenas diferença negativa if (saldoEsperado.trabalhadoMinutos === 0) { linha.push({ content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, rowSpan: saldoEsperado.tamanhoPar }); - } else { + } else { linha.push({ content: `${saldoEsperado.trabalhadoHoras}h ${saldoEsperado.trabalhadoMinutosResto}min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, rowSpan: saldoEsperado.tamanhoPar @@ -1709,30 +1853,32 @@ if (saldoMinutos < 0) saldoMinutos += 24 * 60; const horas = Math.floor(saldoMinutos / 60); const minutos = saldoMinutos % 60; - + // Decrementar saldo acumulado apenas uma vez por par const chavePar = `entrada-sem-registros-${i}`; if (!paresProcessadosParaSaldo.has(chavePar)) { saldoDiarioAcumuladoMinutos -= saldoMinutos; paresProcessadosParaSaldo.add(chavePar); } - + // Calcular saldo acumulado formatado // Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo) // Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo) - const saldoAcumuladoHoras = Math.floor(Math.abs(saldoDiarioAcumuladoMinutos) / 60); + const saldoAcumuladoHoras = Math.floor( + Math.abs(saldoDiarioAcumuladoMinutos) / 60 + ); const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60; // Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-'; const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0; - + // Marcar linha para aplicar cor no saldo if (trabalhouMaisQueEsperado) { linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado } else { linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar } - + // Para dia sem registros, mostrar 0h trabalhado linha.push({ content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, @@ -1746,38 +1892,40 @@ if (saldoMinutos < 0) saldoMinutos += 24 * 60; const horas = Math.floor(saldoMinutos / 60); const minutos = saldoMinutos % 60; - + // Decrementar saldo acumulado apenas uma vez por par const chavePar = `retorno_almoco-sem-registros-${i}`; if (!paresProcessadosParaSaldo.has(chavePar)) { saldoDiarioAcumuladoMinutos -= saldoMinutos; paresProcessadosParaSaldo.add(chavePar); } - + // Calcular saldo acumulado formatado // Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo) // Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo) - const saldoAcumuladoHoras = Math.floor(Math.abs(saldoDiarioAcumuladoMinutos) / 60); + const saldoAcumuladoHoras = Math.floor( + Math.abs(saldoDiarioAcumuladoMinutos) / 60 + ); const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60; // Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-'; const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0; - + // Marcar linha para aplicar cor no saldo if (trabalhouMaisQueEsperado) { linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado } else { linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar } - + // Para dia sem registros, mostrar 0h trabalhado linha.push({ content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, rowSpan: 2 // retorno_almoco + saida }); - } else { - linha.push('-'); - } + } else { + linha.push('-'); + } } else { // Há registros reais mas este par não foi marcado completamente // Verificar se é um par completamente não marcado @@ -1785,7 +1933,7 @@ const saidaEsperadaExiste = todosRegistros.some( (r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real ); - + if (saidaEsperadaExiste) { // Par completamente não marcado: calcular saldo negativo e decrementar saldo acumulado const saldoEsperadoCompleto = saldosEsperadosPorPar.get(i); @@ -1796,43 +1944,47 @@ saldoDiarioAcumuladoMinutos -= saldoEsperadoCompleto.esperadoMinutos; paresProcessadosParaSaldo.add(chavePar); } - + // Calcular saldo acumulado formatado // Se saldoDiarioAcumuladoMinutos > 0: ainda falta trabalhar (mostrar como negativo) // Se saldoDiarioAcumuladoMinutos < 0: trabalhou mais que o esperado (mostrar como positivo) - const saldoAcumuladoHoras = Math.floor(Math.abs(saldoDiarioAcumuladoMinutos) / 60); - const saldoAcumuladoMinutosResto = Math.abs(saldoDiarioAcumuladoMinutos) % 60; + const saldoAcumuladoHoras = Math.floor( + Math.abs(saldoDiarioAcumuladoMinutos) / 60 + ); + const saldoAcumuladoMinutosResto = + Math.abs(saldoDiarioAcumuladoMinutos) % 60; // Inverter sinal: positivo quando trabalhou mais, negativo quando ainda falta const sinalSaldo = saldoDiarioAcumuladoMinutos < 0 ? '+' : '-'; const trabalhouMaisQueEsperado = saldoDiarioAcumuladoMinutos < 0; - + // Marcar linha para aplicar cor no saldo if (trabalhouMaisQueEsperado) { linha._saldoPositivo = true; // Verde: trabalhou mais que o esperado } else { linha._saldoNegativo = true; // Vermelho: ainda falta trabalhar } - + linha.push({ content: `0h 0min parcial | Saldo: ${sinalSaldo}${saldoAcumuladoHoras}h ${saldoAcumuladoMinutosResto}min`, rowSpan: saldoEsperadoCompleto.tamanhoPar }); - } else { + } else { linha.push('-'); - } - } else { - linha.push('-'); - } + } + } else { + linha.push('-'); + } } } else { // Saída não marcada: verificar se faz parte de um par completamente não marcado // Se a entrada correspondente também não foi marcada, o saldo já foi adicionado na linha da entrada // Então apenas não adicionar nada aqui (será coberto pelo rowspan) - const tipoEntradaEsperado = reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco'; + const tipoEntradaEsperado = + reg.tipo === 'saida_almoco' ? 'entrada' : 'retorno_almoco'; const entradaEsperadaExiste = todosRegistros.some( (r, idx) => idx < i && r.tipo === tipoEntradaEsperado && !r.real ); - + if (!entradaEsperadaExiste) { // Saída sem entrada correspondente esperada: não tem saldo linha.push('-'); @@ -1864,7 +2016,7 @@ theme: 'grid', headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, styles: { fontSize: 9 }, - didParseCell: function(data) { + didParseCell: function (data) { // Aplicar cor vermelha para registros não marcados if (data.row.raw && (data.row.raw as any)._naoMarcado) { // Aplicar cor vermelha nas colunas Tipo e Horário @@ -1877,7 +2029,7 @@ if (data.row.raw) { const rowData = data.row.raw as any; const indiceSaldoDiario = sections.saldoDiario ? 3 : -1; - + if (data.column.index === indiceSaldoDiario) { if (rowData._saldoNegativo) { // Saldo negativo: cor vermelha @@ -1916,7 +2068,7 @@ } const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, { - funcionarioId, + funcionarioId }); // Calcular total de dias do período selecionado @@ -1924,21 +2076,40 @@ const totalDiasPeriodo = diasPeriodo.length; // Calcular carga horária diária esperada baseada na configuração - const [horaEntradaConfig, minutoEntradaConfig] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = config.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaEntradaConfig, minutoEntradaConfig] = config.horarioEntrada + .split(':') + .map(Number); + const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = config.horarioSaidaAlmoco + .split(':') + .map(Number); + const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] = config.horarioRetornoAlmoco + .split(':') + .map(Number); const [horaSaidaConfig, minutoSaidaConfig] = config.horarioSaida.split(':').map(Number); // Par 1: entrada -> saida_almoco - const minutosPar1EsperadoConfig = (horaSaidaAlmocoConfig * 60 + minutoSaidaAlmocoConfig) - (horaEntradaConfig * 60 + minutoEntradaConfig); - const minutosPar1EsperadoAjustadoConfig = minutosPar1EsperadoConfig < 0 ? minutosPar1EsperadoConfig + 24 * 60 : minutosPar1EsperadoConfig; + const minutosPar1EsperadoConfig = + horaSaidaAlmocoConfig * 60 + + minutoSaidaAlmocoConfig - + (horaEntradaConfig * 60 + minutoEntradaConfig); + const minutosPar1EsperadoAjustadoConfig = + minutosPar1EsperadoConfig < 0 + ? minutosPar1EsperadoConfig + 24 * 60 + : minutosPar1EsperadoConfig; // Par 2: retorno_almoco -> saida - const minutosPar2EsperadoConfig = (horaSaidaConfig * 60 + minutoSaidaConfig) - (horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig); - const minutosPar2EsperadoAjustadoConfig = minutosPar2EsperadoConfig < 0 ? minutosPar2EsperadoConfig + 24 * 60 : minutosPar2EsperadoConfig; + const minutosPar2EsperadoConfig = + horaSaidaConfig * 60 + + minutoSaidaConfig - + (horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig); + const minutosPar2EsperadoAjustadoConfig = + minutosPar2EsperadoConfig < 0 + ? minutosPar2EsperadoConfig + 24 * 60 + : minutosPar2EsperadoConfig; + + const cargaHorariaDiariaEsperadaMinutos = + minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig; - const cargaHorariaDiariaEsperadaMinutos = minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig; - // Calcular saldos do período selecionado baseado nos saldos diários calculados let saldoPeriodoTrabalhadoMinutos = 0; let diasComSaldoPositivo = 0; @@ -1949,24 +2120,27 @@ // Somar todos os saldos diários do período for (const saldo of Object.values(saldosDiariosPorData)) { saldoPeriodoTrabalhadoMinutos += saldo.trabalhadoMinutos; - + // Calcular diferença diária corretamente: trabalhado - esperado const diferencaDiaria = saldo.trabalhadoMinutos - saldo.esperadoMinutos; - + if (diferencaDiaria > 0) { diasComSaldoPositivo++; } else if (diferencaDiaria < 0) { diasComSaldoNegativo++; } - + if (saldo.trabalhadoMinutos === 0 && saldo.esperadoMinutos > 0) { diasSemRegistros++; } } } else { // Fallback: calcular a partir dos registros se não tiver saldos diários - const registrosPorDataPeriodo: Record> = {}; - + const registrosPorDataPeriodo: Record< + string, + Array<{ tipo: string; hora: number; minuto: number }> + > = {}; + for (const r of registrosFuncionario) { const dataKey = r.data; if (!registrosPorDataPeriodo[dataKey]) { @@ -1975,7 +2149,7 @@ registrosPorDataPeriodo[dataKey]!.push({ tipo: r.tipo, hora: r.hora, - minuto: r.minuto, + minuto: r.minuto }); } @@ -1990,33 +2164,50 @@ // Calcular saldo esperado do período: carga horária diária × número de dias // SEMPRE calcular diretamente, não somar saldos diários esperados (pode duplicar) const saldoPeriodoEsperadoMinutos = cargaHorariaDiariaEsperadaMinutos * totalDiasPeriodo; - + // Calcular diferença do período corretamente: trabalhado - esperado (para "Saldo do Período Exibido") - const saldoPeriodoDiferencaMinutos = saldoPeriodoTrabalhadoMinutos - saldoPeriodoEsperadoMinutos; - + const saldoPeriodoDiferencaMinutos = + saldoPeriodoTrabalhadoMinutos - saldoPeriodoEsperadoMinutos; + // Calcular diferença do período (trabalhado - esperado) para exibição na linha "Diferença do Período" // Negativo quando trabalhado < esperado (vermelho), positivo quando trabalhado > esperado (verde) - const diferencaPeriodoTrabalhadoMenosEsperado = saldoPeriodoTrabalhadoMinutos - saldoPeriodoEsperadoMinutos; + const diferencaPeriodoTrabalhadoMenosEsperado = + saldoPeriodoTrabalhadoMinutos - saldoPeriodoEsperadoMinutos; // Calcular médias diárias - const mediaDiariaTrabalhadaHoras = totalDiasPeriodo > 0 ? Math.floor(saldoPeriodoTrabalhadoMinutos / 60 / totalDiasPeriodo) : 0; - const mediaDiariaTrabalhadaMinutos = totalDiasPeriodo > 0 ? Math.floor((saldoPeriodoTrabalhadoMinutos / totalDiasPeriodo) % 60) : 0; - + const mediaDiariaTrabalhadaHoras = + totalDiasPeriodo > 0 + ? Math.floor(saldoPeriodoTrabalhadoMinutos / 60 / totalDiasPeriodo) + : 0; + const mediaDiariaTrabalhadaMinutos = + totalDiasPeriodo > 0 + ? Math.floor((saldoPeriodoTrabalhadoMinutos / totalDiasPeriodo) % 60) + : 0; + // Calcular média esperada baseada na configuração padrão (não no período) const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number); - const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number); - const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco + .split(':') + .map(Number); + const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco + .split(':') + .map(Number); const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number); // Par 1: entrada -> saida_almoco - const minutosPar1Esperado = (horaSaidaAlmoco * 60 + minutoSaidaAlmoco) - (horaEntrada * 60 + minutoEntrada); - const minutosPar1EsperadoAjustado = minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado; + const minutosPar1Esperado = + horaSaidaAlmoco * 60 + minutoSaidaAlmoco - (horaEntrada * 60 + minutoEntrada); + const minutosPar1EsperadoAjustado = + minutosPar1Esperado < 0 ? minutosPar1Esperado + 24 * 60 : minutosPar1Esperado; // Par 2: retorno_almoco -> saida - const minutosPar2Esperado = (horaSaida * 60 + minutoSaida) - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco); - const minutosPar2EsperadoAjustado = minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado; + const minutosPar2Esperado = + horaSaida * 60 + minutoSaida - (horaRetornoAlmoco * 60 + minutoRetornoAlmoco); + const minutosPar2EsperadoAjustado = + minutosPar2Esperado < 0 ? minutosPar2Esperado + 24 * 60 : minutosPar2Esperado; - const totalEsperadoDiarioMinutos = minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado; + const totalEsperadoDiarioMinutos = + minutosPar1EsperadoAjustado + minutosPar2EsperadoAjustado; const mediaDiariaEsperadaHoras = Math.floor(totalEsperadoDiarioMinutos / 60); const mediaDiariaEsperadaMinutos = totalEsperadoDiarioMinutos % 60; @@ -2029,7 +2220,9 @@ // Diferença do Período: trabalhado - esperado // Negativo quando trabalhado < esperado (vermelho), positivo quando trabalhado > esperado (verde) - const horasDiferencaPeriodo = Math.floor(Math.abs(diferencaPeriodoTrabalhadoMenosEsperado) / 60); + const horasDiferencaPeriodo = Math.floor( + Math.abs(diferencaPeriodoTrabalhadoMenosEsperado) / 60 + ); const minutosDiferencaPeriodo = Math.abs(diferencaPeriodoTrabalhadoMenosEsperado) % 60; const sinalDiferencaPeriodo = diferencaPeriodoTrabalhadoMenosEsperado >= 0 ? '+' : '-'; const diferencaPeriodoFormatado = `${sinalDiferencaPeriodo}${horasDiferencaPeriodo}h ${minutosDiferencaPeriodo}min`; @@ -2088,8 +2281,14 @@ // Adicionar estatísticas bancoHorasData.push(['', '']); // Linha separadora - bancoHorasData.push(['Média Diária de Horas Trabalhadas', `+${mediaDiariaTrabalhadaHoras}h ${mediaDiariaTrabalhadaMinutos}min`]); - bancoHorasData.push(['Média Diária Esperada', `+${mediaDiariaEsperadaHoras}h ${mediaDiariaEsperadaMinutos}min`]); + bancoHorasData.push([ + 'Média Diária de Horas Trabalhadas', + `+${mediaDiariaTrabalhadaHoras}h ${mediaDiariaTrabalhadaMinutos}min` + ]); + bancoHorasData.push([ + 'Média Diária Esperada', + `+${mediaDiariaEsperadaHoras}h ${mediaDiariaEsperadaMinutos}min` + ]); // Adicionar contagens bancoHorasData.push(['', '']); // Linha separadora @@ -2110,7 +2309,7 @@ 0: { fontStyle: 'bold', cellWidth: 80 }, 1: { cellWidth: 'auto' } }, - didParseCell: function(data) { + didParseCell: function (data) { // Ignorar linhas separadoras vazias if (data.cell.text[0] === '' && data.column.index === 0) { data.cell.styles.fillColor = [240, 240, 240]; // Cor de fundo cinza claro @@ -2124,21 +2323,29 @@ if (data.column.index === 1 && valor) { // Aplicar cor no Saldo Atual (vermelho para negativo, azul para positivo) if (campo === 'Saldo Atual') { - if (saldoMinutos < 0) { + if (saldoMinutos < 0) { data.cell.styles.textColor = [200, 0, 0]; // Vermelho para negativo } else if (saldoMinutos > 0) { data.cell.styles.textColor = [0, 100, 200]; // Azul para positivo } data.cell.styles.fontStyle = 'bold'; } - + // Verificar se o valor contém sinal negativo if (valor.includes('-') && !valor.includes('±')) { data.cell.styles.textColor = [200, 0, 0]; // Vermelho para negativo - if (campo === 'Saldo do Período Exibido' || campo === 'Diferença do Período' || campo === 'Resultado Final') { + if ( + campo === 'Saldo do Período Exibido' || + campo === 'Diferença do Período' || + campo === 'Resultado Final' + ) { data.cell.styles.fontStyle = 'bold'; } - } else if (valor.includes('+') || campo.includes('Trabalhado') || campo.includes('Esperado')) { + } else if ( + valor.includes('+') || + campo.includes('Trabalhado') || + campo.includes('Esperado') + ) { // Verde para valores positivos ou campos de trabalhado/esperado if (campo.includes('Diferença do Período')) { // Diferença do Período: trabalhado - esperado @@ -2189,7 +2396,7 @@ const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY; if (finalY) { yPosition = finalY + 10; - } else { + } else { yPosition += bancoHorasData.length * 7 + 10; } } else { @@ -2200,7 +2407,7 @@ body: [['Banco de horas', 'Não disponível']], theme: 'grid', headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, - styles: { fontSize: 9 }, + styles: { fontSize: 9 } }); const lastPage = doc.getNumberOfPages(); @@ -2229,32 +2436,39 @@ doc.setTextColor(0, 0, 0); yPosition += 10; - const homologacoesData = homologacoes.map((h) => { - // Formatar data de criação usando função centralizada (DD/MM/AAAA) - const dataFormatada = formatarDataDDMMAAAA(h.criadoEm); - - if (h.registroId && h.horaAnterior !== undefined) { - return [ - dataFormatada, - 'Edição de Registro', - h.horaAnterior !== undefined - ? `${formatarHoraPonto(h.horaAnterior, h.minutoAnterior || 0)} → ${formatarHoraPonto(h.horaNova || 0, h.minutoNova || 0)}` - : '-', - h.motivoDescricao || h.motivoTipo || '-', - h.observacoes || '-', - ]; - } else if (h.tipoAjuste) { - const tipoAjusteLabel = h.tipoAjuste === 'compensar' ? 'Compensar' : h.tipoAjuste === 'abonar' ? 'Abonar' : 'Descontar'; - return [ - dataFormatada, - `Ajuste: ${tipoAjusteLabel}`, - `${h.periodoDias || 0}d ${h.periodoHoras || 0}h ${h.periodoMinutos || 0}min`, - h.motivoDescricao || h.motivoTipo || '-', - h.observacoes || '-', - ]; - } - return []; - }).filter((row) => row.length > 0); + const homologacoesData = homologacoes + .map((h) => { + // Formatar data de criação usando função centralizada (DD/MM/AAAA) + const dataFormatada = formatarDataDDMMAAAA(h.criadoEm); + + if (h.registroId && h.horaAnterior !== undefined) { + return [ + dataFormatada, + 'Edição de Registro', + h.horaAnterior !== undefined + ? `${formatarHoraPonto(h.horaAnterior, h.minutoAnterior || 0)} → ${formatarHoraPonto(h.horaNova || 0, h.minutoNova || 0)}` + : '-', + h.motivoDescricao || h.motivoTipo || '-', + h.observacoes || '-' + ]; + } else if (h.tipoAjuste) { + const tipoAjusteLabel = + h.tipoAjuste === 'compensar' + ? 'Compensar' + : h.tipoAjuste === 'abonar' + ? 'Abonar' + : 'Descontar'; + return [ + dataFormatada, + `Ajuste: ${tipoAjusteLabel}`, + `${h.periodoDias || 0}d ${h.periodoHoras || 0}h ${h.periodoMinutos || 0}min`, + h.motivoDescricao || h.motivoTipo || '-', + h.observacoes || '-' + ]; + } + return []; + }) + .filter((row) => row.length > 0); if (homologacoesData.length > 0) { autoTable(doc, { @@ -2269,15 +2483,15 @@ 1: { cellWidth: 40 }, // Tipo 2: { cellWidth: 50, cellPadding: { top: 2, bottom: 2, left: 2, right: 2 } }, // Detalhes - maior largura 3: { cellWidth: 40 }, // Motivo - 4: { cellWidth: 'auto' }, // Observações + 4: { cellWidth: 'auto' } // Observações }, - didParseCell: function(data) { + didParseCell: function (data) { // Permitir quebra de linha na coluna Detalhes (índice 2) if (data.column.index === 2) { data.cell.styles.overflow = 'linebreak'; data.cell.styles.cellWidth = 50; } - }, + } }); const lastPage = doc.getNumberOfPages(); @@ -2310,12 +2524,12 @@ // Formatar data de início e fim usando função centralizada (DD/MM/AAAA) const dataInicioFormatada = formatarDataDDMMAAAA(d.dataInicio); const dataFimFormatada = formatarDataDDMMAAAA(d.dataFim); - + return [ `${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`, `${dataFimFormatada} ${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}`, d.motivo, - d.isento ? 'Isento (sem expiração)' : 'Temporária', + d.isento ? 'Isento (sem expiração)' : 'Temporária' ]; }); @@ -2325,7 +2539,7 @@ body: dispensasData, theme: 'grid', headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, - styles: { fontSize: 9 }, + styles: { fontSize: 9 } }); const lastPage = doc.getNumberOfPages(); @@ -2355,7 +2569,7 @@ // Salvar const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`; doc.save(nomeArquivo); - + // Fechar modal após gerar PDF mostrarModalImpressao = false; funcionarioParaImprimir = ''; @@ -2385,7 +2599,7 @@ try { // Buscar dados completos do registro const registro = await client.query(api.pontos.obterRegistro, { registroId }); - + if (!registro) { alert('Registro não encontrado'); return; @@ -2419,7 +2633,7 @@ doc.setTextColor(41, 128, 185); doc.setFont('helvetica', 'bold'); doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, { align: 'center' }); - + // Linha decorativa abaixo do título doc.setDrawColor(41, 128, 185); doc.setLineWidth(0.5); @@ -2442,22 +2656,22 @@ if (funcionarioData.length > 0) { doc.setFontSize(12); doc.setTextColor(41, 128, 185); - doc.setFont('helvetica', 'bold'); + doc.setFont('helvetica', 'bold'); doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); - yPosition += 8; + yPosition += 8; autoTable(doc, { startY: yPosition, head: [['Campo', 'Valor']], body: funcionarioData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [41, 128, 185], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10, textColor: [0, 0, 0] }, @@ -2483,12 +2697,12 @@ nomeEntrada: config.nomeEntrada, nomeSaidaAlmoco: config.nomeSaidaAlmoco, nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, + nomeSaida: config.nomeSaida }) : getTipoRegistroLabel(registro.tipo); const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`; - + const registroData: any[][] = [ ['Tipo', tipoLabel], ['Data e Hora', dataHora], @@ -2501,30 +2715,30 @@ registroData.push(['Justificativa', registro.justificativa]); } - // Verificar se precisa de nova página - if (yPosition > 200) { - doc.addPage(); - yPosition = 20; - } + // Verificar se precisa de nova página + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } doc.setFontSize(12); doc.setTextColor(41, 128, 185); - doc.setFont('helvetica', 'bold'); + doc.setFont('helvetica', 'bold'); doc.text('DADOS DO REGISTRO', 15, yPosition); - yPosition += 8; + yPosition += 8; autoTable(doc, { startY: yPosition, head: [['Campo', 'Valor']], body: registroData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [41, 128, 185], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10, textColor: [0, 0, 0] }, @@ -2602,13 +2816,13 @@ head: [['Campo', 'Valor']], body: localizacaoData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [41, 128, 185], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10, textColor: [0, 0, 0] }, @@ -2623,7 +2837,8 @@ type JsPDFWithAutoTable3 = jsPDF & { lastAutoTable?: { finalY: number }; }; - const finalYLocalizacao = (doc as JsPDFWithAutoTable3).lastAutoTable?.finalY ?? yPosition + 10; + const finalYLocalizacao = + (doc as JsPDFWithAutoTable3).lastAutoTable?.finalY ?? yPosition + 10; yPosition = finalYLocalizacao + 10; } @@ -2653,13 +2868,13 @@ head: [['Campo', 'Valor']], body: gpsData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [41, 128, 185], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10, textColor: [0, 0, 0] }, @@ -2685,7 +2900,10 @@ confiabilidadeData.push(['Confiabilidade GPS (Frontend)', `${confiabilidadePercent}%`]); } - if (registro.scoreConfiancaBackend !== null && registro.scoreConfiancaBackend !== undefined) { + if ( + registro.scoreConfiancaBackend !== null && + registro.scoreConfiancaBackend !== undefined + ) { const scorePercent = (registro.scoreConfiancaBackend * 100).toFixed(1); confiabilidadeData.push(['Score de Confiança (Backend)', `${scorePercent}%`]); } @@ -2693,8 +2911,8 @@ if (confiabilidadeData.length > 0) { doc.setFontSize(11); doc.setTextColor(0, 0, 0); - doc.setFont('helvetica', 'bold'); - doc.text('Confiabilidade:', 15, yPosition); + doc.setFont('helvetica', 'bold'); + doc.text('Confiabilidade:', 15, yPosition); yPosition += 8; autoTable(doc, { @@ -2702,13 +2920,13 @@ head: [['Campo', 'Valor']], body: confiabilidadeData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [60, 60, 60], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10 }, columnStyles: { @@ -2770,13 +2988,13 @@ head: [['Campo', 'Valor']], body: statusData, theme: 'striped', - headStyles: { + headStyles: { fillColor: registro.suspeitaSpoofing ? [200, 0, 0] : [0, 128, 0], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10 }, columnStyles: { @@ -2801,7 +3019,8 @@ type JsPDFWithAutoTable6 = jsPDF & { lastAutoTable?: { finalY: number }; }; - const finalYStatus = (doc as JsPDFWithAutoTable6).lastAutoTable?.finalY ?? yPosition + 10; + const finalYStatus = + (doc as JsPDFWithAutoTable6).lastAutoTable?.finalY ?? yPosition + 10; yPosition = finalYStatus + 5; } } @@ -2809,7 +3028,7 @@ // Avisos de Validação em tabela if (registro.avisosValidacao && registro.avisosValidacao.length > 0) { const avisosData = registro.avisosValidacao.map((aviso: string) => ['', aviso]); - + doc.setFontSize(11); doc.setTextColor(0, 0, 0); doc.setFont('helvetica', 'bold'); @@ -2821,13 +3040,13 @@ head: [['', 'Aviso']], body: avisosData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [255, 165, 0], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10, textColor: [0, 0, 0] }, @@ -2851,21 +3070,33 @@ let propriedadesGPS = 0; let propriedadesTotais = 5; - if (registro.altitude !== null && registro.altitude !== undefined && registro.altitude !== 0) { + if ( + registro.altitude !== null && + registro.altitude !== undefined && + registro.altitude !== 0 + ) { propriedadesData.push(['Altitude', '✓ Disponível']); propriedadesGPS++; } else { propriedadesData.push(['Altitude', '✗ Não disponível']); } - if (registro.altitudeAccuracy !== null && registro.altitudeAccuracy !== undefined && registro.altitudeAccuracy > 0) { + if ( + registro.altitudeAccuracy !== null && + registro.altitudeAccuracy !== undefined && + registro.altitudeAccuracy > 0 + ) { propriedadesData.push(['Precisão de Altitude', '✓ Disponível']); propriedadesGPS++; } else { propriedadesData.push(['Precisão de Altitude', '✗ Não disponível']); } - if (registro.heading !== null && registro.heading !== undefined && !isNaN(registro.heading)) { + if ( + registro.heading !== null && + registro.heading !== undefined && + !isNaN(registro.heading) + ) { propriedadesData.push(['Direção (Heading)', '✓ Disponível']); propriedadesGPS++; } else { @@ -2879,10 +3110,19 @@ propriedadesData.push(['Velocidade', '✗ Não disponível']); } - if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao < 20) { + if ( + registro.precisao !== null && + registro.precisao !== undefined && + registro.precisao < 20 + ) { propriedadesData.push(['Precisão GPS', '✓ Alta precisão (< 20m)']); propriedadesGPS++; - } else if (registro.precisao !== null && registro.precisao !== undefined && registro.precisao >= 20 && registro.precisao < 100) { + } else if ( + registro.precisao !== null && + registro.precisao !== undefined && + registro.precisao >= 20 && + registro.precisao < 100 + ) { propriedadesData.push(['Precisão GPS', '⚠ Precisão média (20-100m)']); propriedadesGPS += 0.5; } else { @@ -2891,8 +3131,14 @@ // Indicador de qualidade GPS const qualidadeGPS = (propriedadesGPS / propriedadesTotais) * 100; - const qualidadeTexto = qualidadeGPS >= 80 ? 'Alta qualidade (GPS real)' : qualidadeGPS >= 50 ? 'Qualidade média' : 'Baixa qualidade (possível spoofing)'; - const qualidadeCor = qualidadeGPS >= 80 ? [0, 128, 0] : qualidadeGPS >= 50 ? [255, 165, 0] : [255, 0, 0]; + const qualidadeTexto = + qualidadeGPS >= 80 + ? 'Alta qualidade (GPS real)' + : qualidadeGPS >= 50 + ? 'Qualidade média' + : 'Baixa qualidade (possível spoofing)'; + const qualidadeCor = + qualidadeGPS >= 80 ? [0, 128, 0] : qualidadeGPS >= 50 ? [255, 165, 0] : [255, 0, 0]; propriedadesData.push(['Qualidade GPS', `${qualidadeTexto} (${qualidadeGPS.toFixed(0)}%)`]); doc.setFontSize(11); @@ -2906,13 +3152,13 @@ head: [['Propriedade', 'Status']], body: propriedadesData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [60, 60, 60], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10 }, columnStyles: { @@ -2945,7 +3191,8 @@ type JsPDFWithAutoTable8 = jsPDF & { lastAutoTable?: { finalY: number }; }; - const finalYPropriedades = (doc as JsPDFWithAutoTable8).lastAutoTable?.finalY ?? yPosition + 10; + const finalYPropriedades = + (doc as JsPDFWithAutoTable8).lastAutoTable?.finalY ?? yPosition + 10; yPosition = finalYPropriedades + 10; } @@ -2972,10 +3219,9 @@ if (registro.enderecoMarcacaoEsperado) { try { - const enderecoEsperado = await client.query( - api.enderecosMarcacao.obterEndereco, - { enderecoId: registro.enderecoMarcacaoEsperado } - ); + const enderecoEsperado = await client.query(api.enderecosMarcacao.obterEndereco, { + enderecoId: registro.enderecoMarcacaoEsperado + }); if (enderecoEsperado) { enderecoEsperadoNome = enderecoEsperado.nome; enderecoEsperadoEndereco = `${enderecoEsperado.endereco}, ${enderecoEsperado.cidade}/${enderecoEsperado.estado}`; @@ -2993,28 +3239,39 @@ ]; if (enderecoEsperadoLatitude !== null && enderecoEsperadoLongitude !== null) { - geofencingData.push(['Coordenadas Esperadas', `${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}`]); + geofencingData.push([ + 'Coordenadas Esperadas', + `${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}` + ]); } - geofencingData.push(['Coordenadas do Registro', `${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`]); + geofencingData.push([ + 'Coordenadas do Registro', + `${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}` + ]); - if (registro.distanciaEnderecoEsperado !== null && registro.distanciaEnderecoEsperado !== undefined) { + if ( + registro.distanciaEnderecoEsperado !== null && + registro.distanciaEnderecoEsperado !== undefined + ) { const distanciaKm = (registro.distanciaEnderecoEsperado / 1000).toFixed(2); const distanciaMetros = registro.distanciaEnderecoEsperado.toFixed(0); - const distanciaTexto = registro.distanciaEnderecoEsperado >= 1000 - ? `${distanciaKm} km (${distanciaMetros} metros)` - : `${distanciaMetros} metros`; + const distanciaTexto = + registro.distanciaEnderecoEsperado >= 1000 + ? `${distanciaKm} km (${distanciaMetros} metros)` + : `${distanciaMetros} metros`; geofencingData.push(['Distância', distanciaTexto]); } if (registro.raioToleranciaUsado !== null && registro.raioToleranciaUsado !== undefined) { const raioKm = (registro.raioToleranciaUsado / 1000).toFixed(2); const raioMetros = registro.raioToleranciaUsado.toFixed(0); - const raioTexto = registro.raioToleranciaUsado >= 1000 - ? `${raioKm} km (${raioMetros} metros)` - : `${raioMetros} metros`; + const raioTexto = + registro.raioToleranciaUsado >= 1000 + ? `${raioKm} km (${raioMetros} metros)` + : `${raioMetros} metros`; geofencingData.push(['Raio Permitido', raioTexto]); - } else { + } else { geofencingData.push(['Raio Permitido', 'Não configurado']); } @@ -3030,15 +3287,17 @@ registro.raioToleranciaUsado !== null && registro.raioToleranciaUsado !== undefined ) { - const distanciaExcedente = registro.distanciaEnderecoEsperado - registro.raioToleranciaUsado; + const distanciaExcedente = + registro.distanciaEnderecoEsperado - registro.raioToleranciaUsado; const distanciaExcedenteKm = (distanciaExcedente / 1000).toFixed(2); const distanciaExcedenteMetros = distanciaExcedente.toFixed(0); - const excedenteTexto = distanciaExcedente >= 1000 - ? `${distanciaExcedenteKm} km além do permitido` - : `${distanciaExcedenteMetros} metros além do permitido`; + const excedenteTexto = + distanciaExcedente >= 1000 + ? `${distanciaExcedenteKm} km além do permitido` + : `${distanciaExcedenteMetros} metros além do permitido`; geofencingData.push(['Distância Excedente', excedenteTexto]); - } } + } geofencingData.push(['Status', statusTexto]); autoTable(doc, { @@ -3046,13 +3305,13 @@ head: [['Campo', 'Valor']], body: geofencingData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [41, 128, 185], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 10 }, columnStyles: { @@ -3078,7 +3337,8 @@ type JsPDFWithAutoTable9 = jsPDF & { lastAutoTable?: { finalY: number }; }; - const finalYGeofencing = (doc as JsPDFWithAutoTable9).lastAutoTable?.finalY ?? yPosition + 10; + const finalYGeofencing = + (doc as JsPDFWithAutoTable9).lastAutoTable?.finalY ?? yPosition + 10; yPosition = finalYGeofencing + 5; // Observação se fora do raio @@ -3099,7 +3359,11 @@ } else { doc.setFontSize(10); doc.setTextColor(100, 100, 100); - doc.text('Validação de localização permitida não configurada para este registro.', 15, yPosition); + doc.text( + 'Validação de localização permitida não configurada para este registro.', + 15, + yPosition + ); yPosition += 8; doc.setTextColor(0, 0, 0); } @@ -3137,7 +3401,10 @@ // Informações do Navegador if (registro.browser || registro.userAgent) { if (registro.browser) { - dadosTecnicosData.push(['Navegador', `${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`]); + dadosTecnicosData.push([ + 'Navegador', + `${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}` + ]); } if (registro.engine) { dadosTecnicosData.push(['Engine', registro.engine]); @@ -3150,7 +3417,10 @@ // Informações do Sistema if (registro.sistemaOperacional || registro.arquitetura) { if (registro.sistemaOperacional) { - dadosTecnicosData.push(['Sistema Operacional', `${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`]); + dadosTecnicosData.push([ + 'Sistema Operacional', + `${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}` + ]); } if (registro.arquitetura) { dadosTecnicosData.push(['Arquitetura', registro.arquitetura]); @@ -3175,7 +3445,11 @@ dadosTecnicosData.push(['Cores da Tela', registro.coresTela]); } if (registro.isMobile || registro.isTablet || registro.isDesktop) { - const tipoDispositivo = registro.isMobile ? 'Mobile' : registro.isTablet ? 'Tablet' : 'Desktop'; + const tipoDispositivo = registro.isMobile + ? 'Mobile' + : registro.isTablet + ? 'Tablet' + : 'Desktop'; dadosTecnicosData.push(['Categoria', tipoDispositivo]); } if (registro.idioma) { @@ -3195,13 +3469,13 @@ head: [['Campo', 'Valor']], body: dadosTecnicosData, theme: 'striped', - headStyles: { + headStyles: { fillColor: [60, 60, 60], textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 10 }, - bodyStyles: { + bodyStyles: { fontSize: 9, textColor: [0, 0, 0] }, @@ -3216,7 +3490,8 @@ type JsPDFWithAutoTable10 = jsPDF & { lastAutoTable?: { finalY: number }; }; - const finalYTecnicos = (doc as JsPDFWithAutoTable10).lastAutoTable?.finalY ?? yPosition + 10; + const finalYTecnicos = + (doc as JsPDFWithAutoTable10).lastAutoTable?.finalY ?? yPosition + 10; yPosition = finalYTecnicos + 10; } @@ -3240,10 +3515,10 @@ if (!response.ok) { throw new Error('Erro ao carregar imagem'); } - + const blob = await response.blob(); const reader = new FileReader(); - + // Converter blob para base64 const base64 = await new Promise((resolve, reject) => { reader.onloadend = () => { @@ -3287,7 +3562,7 @@ // Centralizar imagem const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2; - + // Verificar se cabe na página atual if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) { doc.addPage(); @@ -3309,19 +3584,24 @@ const pageCount = doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { doc.setPage(i); - + // Linha decorativa no rodapé doc.setDrawColor(200, 200, 200); doc.setLineWidth(0.3); - doc.line(15, doc.internal.pageSize.getHeight() - 20, 195, doc.internal.pageSize.getHeight() - 20); - + doc.line( + 15, + doc.internal.pageSize.getHeight() - 20, + 195, + doc.internal.pageSize.getHeight() - 20 + ); + // Texto do rodapé doc.setFontSize(8); doc.setTextColor(100, 100, 100); doc.setFont('helvetica', 'normal'); - const dataGeracao = new Date().toLocaleDateString('pt-BR', { - day: '2-digit', - month: '2-digit', + const dataGeracao = new Date().toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' @@ -3350,27 +3630,34 @@ } -
+
-
+
-
- +
+
-

+

Registro de Pontos

- Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e relatórios + Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e + relatórios

{#if estatisticas} -
+

Total de Registros

{estatisticas.totalRegistros}

@@ -3379,7 +3666,9 @@

Funcionários

{estatisticas.totalFuncionarios}

-
+
{estatisticas.totalRegistros > 0 @@ -3395,16 +3684,18 @@ {#if estatisticas} -
+
-
+
-

Total de Registros

-

{estatisticas.totalRegistros}

+

Total de Registros

+

{estatisticas.totalRegistros}

-
+
@@ -3412,19 +3703,21 @@
-
+
-

Dentro do Prazo

+

Dentro do Prazo

{estatisticas.dentroDoPrazo}

-

+

{estatisticas.totalRegistros > 0 ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) : 0}% do total

-
+
@@ -3432,19 +3725,21 @@
-
+
-

Fora do Prazo

+

Fora do Prazo

{estatisticas.foraDoPrazo}

-

+

{estatisticas.totalRegistros > 0 ? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) : 0}% do total

-
+
@@ -3452,17 +3747,19 @@
-
+
-

Funcionários

+

Funcionários

{estatisticas.totalFuncionarios}

-

+

{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora

-
+
@@ -3472,62 +3769,66 @@ {/if} -
-
-
-

-
- -
- Visão Geral das Estatísticas -

-
-
+
+
+
+

+
+ +
+ Visão Geral das Estatísticas +

+
+
{#if estatisticasQuery === undefined || estatisticasQuery?.isLoading} -
+
Carregando estatísticas...
{:else if estatisticasQuery?.error} -
+

Erro ao carregar estatísticas

-
{estatisticasQuery.error?.message || String(estatisticasQuery.error) || 'Erro desconhecido'}
+
+ {estatisticasQuery.error?.message || + String(estatisticasQuery.error) || + 'Erro desconhecido'} +
{:else if !estatisticas || !chartData} -
+
- +

Nenhuma estatística disponível

{:else} - + {/if} -
+
-
+
-
+
-
- +
+
-

Filtros de Busca

+

Filtros de Busca

-
+
@@ -3565,7 +3866,7 @@ id="data-fim" type="date" bind:value={dataFim} - class="input input-bordered input-primary focus:input-primary focus:ring-2 focus:ring-primary/20" + class="input input-bordered input-primary focus:input-primary focus:ring-primary/20 focus:ring-2" />
@@ -3576,7 +3877,7 @@ @@ -3607,7 +3908,7 @@
- + {#if statusFiltro !== 'todos' || localizacaoFiltro !== 'todos'} -
-

- {registrosFiltrados.length} registro(s) encontrado(s) com os filtros aplicados +

+

+ {registrosFiltrados.length} registro(s) encontrado(s) com os filtros + aplicados {#if registros.length !== registrosFiltrados.length} - + (de {registros.length} total) {/if} @@ -3633,19 +3935,19 @@

-
+
-
+
-
- +
+
-

Registros de Ponto

+

Registros de Ponto

- + {#if funcionarioIdFiltro || dataInicio || dataFim} -
+
{#if funcionarioIdFiltro && funcionarioSelecionadoNome}
@@ -3669,44 +3971,60 @@
{#if registrosQuery === undefined || registrosQuery?.isLoading} -
+
Carregando registros... - Aguarde um momento + Aguarde um momento
{:else if registrosQuery?.error} -
+

Erro ao carregar registros

-
{registrosQuery.error?.message || String(registrosQuery.error) || 'Erro desconhecido'}
+
+ {registrosQuery.error?.message || String(registrosQuery.error) || 'Erro desconhecido'} +
{:else if !registrosQuery?.data} -
+
Aguardando dados da consulta...
{:else if registros.length === 0} -
- +
+
-

Nenhum registro encontrado

-
-

Período: {formatarDataDDMMAAAA(dataInicio)} até {formatarDataDDMMAAAA(dataFim)}

+

Nenhum registro encontrado

+
+

+ Período: {formatarDataDDMMAAAA(dataInicio)} até {formatarDataDDMMAAAA(dataFim)} +

{#if funcionarioIdFiltro && funcionarioSelecionadoNome} -

Funcionário: {funcionarioSelecionadoNome}

+

+ Funcionário: {funcionarioSelecionadoNome} +

{/if} -

Tente ajustar os filtros para encontrar registros.

+

+ Tente ajustar os filtros para encontrar registros. +

{:else if registrosAgrupados.length === 0} -
- +
+

Registros encontrados, mas não foi possível agrupá-los

-
+
Total de registros: {registros.length}
@@ -3714,89 +4032,133 @@ {:else}
{#each registrosAgrupados as grupo} -
+
-
-
+
+
-
-
- +
+
+
-

+

{grupo.funcionario?.nome || 'Funcionário não encontrado'}

{#if grupo.funcionario?.matricula} -

- Matrícula: {grupo.funcionario.matricula} +

+ Matrícula: + {grupo.funcionario.matricula}

{/if}
{#if grupo.funcionario?.descricaoCargo} -

+

{grupo.funcionario.descricaoCargo}

{/if}
- -
- - {#key grupo.funcionarioId} - {@const bancoHorasQuery = useQuery( - api.pontos.obterBancoHorasFuncionario, - { funcionarioId: grupo.funcionarioId } - )} - {@const bancoHoras = bancoHorasQuery?.data} - {@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0} - {@const saldoPositivo = saldoAcumulado >= 0} - - {#if bancoHoras} -
-
-
- {#if saldoPositivo} - - {:else} - - {/if} -
-
-

Banco de Horas

-

- {formatarSaldoHoras(saldoAcumulado)} -

+ +
+ + {#key grupo.funcionarioId} + {@const bancoHorasQuery = useQuery(api.pontos.obterBancoHorasFuncionario, { + funcionarioId: grupo.funcionarioId + })} + {@const bancoHoras = bancoHorasQuery?.data} + {@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0} + {@const saldoPositivo = saldoAcumulado >= 0} + + {#if bancoHoras} +
+
+
+ {#if saldoPositivo} + + {:else} + + {/if} +
+
+

Banco de Horas

+

+ {formatarSaldoHoras(saldoAcumulado)} +

+
-
- {/if} - {/key} - - -
+ {/if} + {/key} + + +
-
- - +
+
+ - - - - - - - - + + + + + + + + @@ -3804,27 +4166,38 @@ {@const totalRegistros = grupoData.registros.length} {@const dataFormatada = formatarDataDDMMAAAA(grupoData.data)} {@const saldosParciais = calcularSaldosParciais(grupoData.registros)} - {@const isUltimoDia = dataIndex === Object.values(grupo.registrosPorData).length - 1} + {@const isUltimoDia = + dataIndex === Object.values(grupo.registrosPorData).length - 1} {#each grupoData.registros as registro, index} {@const saldoParcial = saldosParciais.get(index)} - - + + - + + + {/each} + +
DataTipoHorárioSaldo ParcialSaldo DiárioLocalizaçãoStatusAçõesDataTipoHorárioSaldo ParcialSaldo DiárioLocalizaçãoStatusAções
{dataFormatada}
{dataFormatada} - - {config - ? getTipoRegistroLabel(registro.tipo, { - nomeEntrada: config.nomeEntrada, - nomeSaidaAlmoco: config.nomeSaidaAlmoco, - nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, - }) - : getTipoRegistroLabel(registro.tipo)} + + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida + }) + : getTipoRegistroLabel(registro.tipo)} {formatarHoraPonto(registro.hora, registro.minuto)}{formatarHoraPonto(registro.hora, registro.minuto)} {#if saldoParcial} - + Par {saldoParcial.parNumero}: +{saldoParcial.horas}h {saldoParcial.minutos}min {:else} @@ -3834,9 +4207,12 @@ {#if index === 0} {#if grupoData.saldoDiarioComparativo} - + {:else if grupoData.saldoDiario} - + {:else} - {/if} @@ -3847,14 +4223,16 @@ {registro.dentroDoPrazo ? '✓ Dentro do Prazo' : '✗ Fora do Prazo'} - +
{#if registroDetalhesQuery === undefined || registroDetalhesQuery?.isLoading}
@@ -3911,7 +4300,11 @@

Erro ao carregar detalhes

-
{registroDetalhesQuery.error?.message || String(registroDetalhesQuery.error) || 'Erro desconhecido'}
+
+ {registroDetalhesQuery.error?.message || + String(registroDetalhesQuery.error) || + 'Erro desconhecido'} +
{:else if !registroDetalhes} @@ -3923,7 +4316,7 @@
-

Informações do Registro

+

Informações do Registro

{#if registroDetalhes.funcionario}

Funcionário: {registroDetalhes.funcionario.nome}

{#if registroDetalhes.funcionario.matricula} @@ -3931,8 +4324,17 @@ {/if} {/if}

Data: {formatarDataDDMMAAAA(registroDetalhes.data)}

-

Horário: {formatarHoraPonto(registroDetalhes.hora, registroDetalhes.minuto)}

-

Status: {registroDetalhes.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}

+

+ Horário: + {formatarHoraPonto(registroDetalhes.hora, registroDetalhes.minuto)} +

+

+ Status: + {registroDetalhes.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} +

@@ -3940,20 +4342,31 @@ {#if registroDetalhes.latitude !== undefined && registroDetalhes.longitude !== undefined}
-

Localização GPS

+

Localização GPS

Latitude: {registroDetalhes.latitude.toFixed(6)}

Longitude: {registroDetalhes.longitude.toFixed(6)}

{#if registroDetalhes.precisao !== undefined}

Precisão: {registroDetalhes.precisao.toFixed(2)}m

{/if} {#if registroDetalhes.endereco || registroDetalhes.cidade} -

Endereço: {registroDetalhes.endereco || ''} {registroDetalhes.cidade ? `, ${registroDetalhes.cidade}` : ''} {registroDetalhes.estado ? ` - ${registroDetalhes.estado}` : ''}

+

+ Endereço: + {registroDetalhes.endereco || ''} + {registroDetalhes.cidade ? `, ${registroDetalhes.cidade}` : ''} + {registroDetalhes.estado ? ` - ${registroDetalhes.estado}` : ''} +

{/if} {#if registroDetalhes.confiabilidadeGPS !== undefined} -

Confiabilidade GPS: {(registroDetalhes.confiabilidadeGPS * 100).toFixed(1)}%

+

+ Confiabilidade GPS: + {(registroDetalhes.confiabilidadeGPS * 100).toFixed(1)}% +

{/if} {#if registroDetalhes.scoreConfiancaBackend !== undefined} -

Score de Confiança: {(registroDetalhes.scoreConfiancaBackend * 100).toFixed(1)}%

+

+ Score de Confiança: + {(registroDetalhes.scoreConfiancaBackend * 100).toFixed(1)}% +

{/if}
@@ -3963,31 +4376,58 @@ {#if registroDetalhes.acelerometroX !== undefined || registroDetalhes.sensorDisponivel !== undefined}
-

Dados de Sensores

+

Dados de Sensores

{#if registroDetalhes.sensorDisponivel === false && registroDetalhes.isDesktop !== true} -

Sensor: Não disponível neste dispositivo

+

+ Sensor: Não disponível neste dispositivo +

{:else if registroDetalhes.permissaoSensorNegada === true}

Sensor: Permissão negada

{:else if registroDetalhes.acelerometroX !== undefined}

Sensor: Disponível

-

Acelerômetro X: {registroDetalhes.acelerometroX.toFixed(3)} m/s²

+

+ Acelerômetro X: + {registroDetalhes.acelerometroX.toFixed(3)} m/s² +

{#if registroDetalhes.acelerometroY !== undefined} -

Acelerômetro Y: {registroDetalhes.acelerometroY.toFixed(3)} m/s²

+

+ Acelerômetro Y: + {registroDetalhes.acelerometroY.toFixed(3)} m/s² +

{/if} {#if registroDetalhes.acelerometroZ !== undefined} -

Acelerômetro Z: {registroDetalhes.acelerometroZ.toFixed(3)} m/s²

+

+ Acelerômetro Z: + {registroDetalhes.acelerometroZ.toFixed(3)} m/s² +

{/if} {#if registroDetalhes.magnitudeMovimento !== undefined} -

Magnitude: {registroDetalhes.magnitudeMovimento.toFixed(3)} m/s²

+

+ Magnitude: + {registroDetalhes.magnitudeMovimento.toFixed(3)} m/s² +

{/if} {#if registroDetalhes.movimentoDetectado !== undefined} -

Movimento Detectado: {registroDetalhes.movimentoDetectado ? 'Sim' : 'Não'}

+

+ Movimento Detectado: + {registroDetalhes.movimentoDetectado ? 'Sim' : 'Não'} +

{/if} {#if registroDetalhes.variacaoAcelerometro !== undefined} -

Variação: {registroDetalhes.variacaoAcelerometro.toFixed(6)}

+

+ Variação: + {registroDetalhes.variacaoAcelerometro.toFixed(6)} +

{/if} {:else if registroDetalhes.isDesktop === true} -

Sensor: Não disponível em desktop (normal)

+

+ Sensor: Não disponível em desktop (normal) +

{/if}
@@ -3995,9 +4435,12 @@ {/if}
-
+
{#if registroDetalhes} - @@ -4008,4 +4451,3 @@ {/if} - diff --git a/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte b/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte index 491ed3a..64e998d 100644 --- a/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte @@ -1,1593 +1,1780 @@ -
- - +
+ + - -
-
-
- - - -
-
-

Central de Chamados

-

Monitore tickets, configure SLA, atribua responsáveis e acompanhe alertas de prazos

-
-
-
+ +
+
+
+ + + +
+
+

Central de Chamados

+

+ Monitore tickets, configure SLA, atribua responsáveis e acompanhe alertas de prazos +

+
+
+
- -
- - - -
+ +
+ + + +
-
- - {#if abaAtiva === 'dashboard'} -
-
-

Total de chamados

-

{estatisticas.total ?? 0}

-
-
-

Abertos

-

{estatisticas.abertos ?? 0}

-
-
-

Em andamento

-

{estatisticas.emAndamento ?? 0}

-
-
-

Vencidos/Cancelados

-

{estatisticas.vencidos ?? 0}

-
-
+
+ + {#if abaAtiva === 'dashboard'} +
+
+

Total de chamados

+

{estatisticas.total ?? 0}

+
+
+

Abertos

+

{estatisticas.abertos ?? 0}

+
+
+

Em andamento

+

{estatisticas.emAndamento ?? 0}

+
+
+

Vencidos/Cancelados

+

{estatisticas.vencidos ?? 0}

+
+
- -
-
-
-

Performance de SLA

-

Monitoramento em tempo real do cumprimento de SLA por prioridade

-
- {#if dadosSlaGraficoQuery !== undefined && dadosSlaGraficoQuery !== null} - {@const dadosSla = typeof dadosSlaGraficoQuery === 'object' && 'data' in dadosSlaGraficoQuery - ? dadosSlaGraficoQuery.data - : (typeof dadosSlaGraficoQuery === 'object' && 'taxaCumprimento' in dadosSlaGraficoQuery - ? dadosSlaGraficoQuery - : null)} - {#if dadosSla} -
-
-

Taxa de Cumprimento

-

- {dadosSla.taxaCumprimento}% -

-
-
-

Última atualização

-

- {new Date(dadosSla.atualizadoEm).toLocaleTimeString('pt-BR')} -

-
-
- {/if} - {/if} -
- - {#if dadosSlaGraficoQuery === undefined || dadosSlaGraficoQuery === null} -
- -
- {:else} - {@const dadosSla = typeof dadosSlaGraficoQuery === 'object' && 'data' in dadosSlaGraficoQuery - ? dadosSlaGraficoQuery.data - : (typeof dadosSlaGraficoQuery === 'object' && 'taxaCumprimento' in dadosSlaGraficoQuery - ? dadosSlaGraficoQuery - : null)} - {#if dadosSla} -
-
-

Dentro do Prazo

-

{dadosSla.statusSla.dentroPrazo}

-
-
-

Próximo Vencimento

-

{dadosSla.statusSla.proximoVencimento}

-
-
-

Vencidos

-

{dadosSla.statusSla.vencido}

-
-
-

Sem Prazo

-

{dadosSla.statusSla.semPrazo}

-
-
- - {:else} -
-

Carregando dados de SLA...

-
- {/if} - {/if} -
- {/if} + +
+
+
+

Performance de SLA

+

+ Monitoramento em tempo real do cumprimento de SLA por prioridade +

+
+ {#if dadosSlaGraficoQuery !== undefined && dadosSlaGraficoQuery !== null} + {@const dadosSla = + typeof dadosSlaGraficoQuery === 'object' && 'data' in dadosSlaGraficoQuery + ? dadosSlaGraficoQuery.data + : typeof dadosSlaGraficoQuery === 'object' && + 'taxaCumprimento' in dadosSlaGraficoQuery + ? dadosSlaGraficoQuery + : null} + {#if dadosSla} +
+
+

Taxa de Cumprimento

+

+ {dadosSla.taxaCumprimento}% +

+
+
+

Última atualização

+

+ {new Date(dadosSla.atualizadoEm).toLocaleTimeString('pt-BR')} +

+
+
+ {/if} + {/if} +
- - {#if abaAtiva === 'chamados'} -
-
-
-

Painel de chamados

-

- Filtros por status, responsável e setor. -

-
-
- - - -
-
+ {#if dadosSlaGraficoQuery === undefined || dadosSlaGraficoQuery === null} +
+ +
+ {:else} + {@const dadosSla = + typeof dadosSlaGraficoQuery === 'object' && 'data' in dadosSlaGraficoQuery + ? dadosSlaGraficoQuery.data + : typeof dadosSlaGraficoQuery === 'object' && + 'taxaCumprimento' in dadosSlaGraficoQuery + ? dadosSlaGraficoQuery + : null} + {#if dadosSla} +
+
+

Dentro do Prazo

+

{dadosSla.statusSla.dentroPrazo}

+
+
+

Próximo Vencimento

+

{dadosSla.statusSla.proximoVencimento}

+
+
+

Vencidos

+

{dadosSla.statusSla.vencido}

+
+
+

Sem Prazo

+

{dadosSla.statusSla.semPrazo}

+
+
+ + {:else} +
+

Carregando dados de SLA...

+
+ {/if} + {/if} +
+ {/if} -
- - - - - - - - - - - - - {#if carregandoChamados} - - - - {:else if tickets.length === 0} - - - - {:else} - {#each tickets as ticket (ticket._id)} - selecionarTicket(ticket._id)} - > - - - - - - - - {/each} - {/if} - -
TicketTipoStatusResponsávelPrioridadePrazo
-
- -
-
- Nenhum chamado encontrado. -
-
{ticket.numero}
-
{ticket.solicitanteNome}
-
{ticket.tipo} - {getStatusLabel(ticket.status)} - {(ticket as Ticket & { responsavelNome?: string }).responsavelNome ?? ticket.setorResponsavel ?? "—"}{ticket.prioridade} - {ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"} -
-
-
+ + {#if abaAtiva === 'chamados'} +
+
+
+

Painel de chamados

+

Filtros por status, responsável e setor.

+
+
+ + + +
+
- - {#if !detalheSelecionado} -
-
- - - -

Nenhum chamado selecionado

-

- Selecione um chamado na tabela acima para visualizar detalhes e realizar ações. -

-
-
- {:else} - -
- -
-
-
-
-
- {detalheSelecionado.numero} -
- - {getStatusLabel(detalheSelecionado.status)} - -
-

{detalheSelecionado.titulo}

-
-
- - - - Solicitante: - {detalheSelecionado.solicitanteNome} -
- {#if detalheSelecionado.prazoConclusao} -
- - - - Prazo: - {prazoRestante(detalheSelecionado.prazoConclusao) ?? "--"} -
- {/if} -
-
-
-
+
+ + + + + + + + + + + + + {#if carregandoChamados} + + + + {:else if tickets.length === 0} + + + + {:else} + {#each tickets as ticket (ticket._id)} + selecionarTicket(ticket._id)} + > + + + + + + + + {/each} + {/if} + +
TicketTipoStatusResponsávelPrioridadePrazo
+
+ +
+
+ Nenhum chamado encontrado. +
+
{ticket.numero}
+
{ticket.solicitanteNome}
+
{ticket.tipo} + {getStatusLabel(ticket.status)} + {(ticket as Ticket & { responsavelNome?: string }).responsavelNome ?? + ticket.setorResponsavel ?? + '—'}{ticket.prioridade} + {ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : '--'} +
+
+
- -
- -
-
- - - -

Detalhes do chamado

-
-
-
-

Descrição

-

- {detalheSelecionado.descricao} -

-
-
-
-

Prazo de resposta

-

- {prazoRestante(detalheSelecionado.prazoResposta) ?? "--"} -

-
-
-

Prazo de conclusão

-

- {prazoRestante(detalheSelecionado.prazoConclusao) ?? "--"} -

-
-
-
-

Histórico e Timeline

- -
-
-
+ + {#if !detalheSelecionado} +
+
+ + + +

Nenhum chamado selecionado

+

+ Selecione um chamado na tabela acima para visualizar detalhes e realizar ações. +

+
+
+ {:else} + +
+ +
+
+
+
+
+ {detalheSelecionado.numero} +
+ + {getStatusLabel(detalheSelecionado.status)} + +
+

+ {detalheSelecionado.titulo} +

+
+
+ + + + Solicitante: + {detalheSelecionado.solicitanteNome} +
+ {#if detalheSelecionado.prazoConclusao} +
+ + + + Prazo: + {prazoRestante(detalheSelecionado.prazoConclusao) ?? '--'} +
+ {/if} +
+
+
+
- -
-
- - - -

Responder chamado

-
-
-
- - -
+ +
+ +
+
+ + + +

Detalhes do chamado

+
+
+
+

Descrição

+

+ {detalheSelecionado.descricao} +

+
+
+
+

Prazo de resposta

+

+ {prazoRestante(detalheSelecionado.prazoResposta) ?? '--'} +

+
+
+

Prazo de conclusão

+

+ {prazoRestante(detalheSelecionado.prazoConclusao) ?? '--'} +

+
+
+
+

Histórico e Timeline

+ +
+
+
-
- - - {#if respostaAnexoNome} -
- {respostaAnexoNome} - -
- {/if} -
+ +
+
+ + + +

Responder chamado

+
+
+
+ + +
- {#if respostaFeedback} -
- {respostaFeedback} -
- {/if} +
+ + + {#if respostaAnexoNome} +
+ {respostaAnexoNome} + +
+ {/if} +
-
- - -
-
-
+ {#if respostaFeedback} +
+ {respostaFeedback} +
+ {/if} - -
-
- - - -

Atribuir responsável

-
-
- - - {#if assignFeedback} -
- {assignFeedback} -
- {/if} - -
-
+
+ + +
+
+
- -
-
- - - -
-

Prorrogar prazo

-

Recurso exclusivo para a equipe de TI

-
-
-
-
- - -
-
- - -
-
- - -
- {#if prorrogacaoFeedback} -
- {prorrogacaoFeedback} -
- {/if} - -
-
-
-
- {/if} - {/if} + +
+
+ + + +

Atribuir responsável

+
+
+ + + {#if assignFeedback} +
+ {assignFeedback} +
+ {/if} + +
+
- - {#if abaAtiva === 'sla'} - -
-
-
-

SLAs Configurados

-

Visualize todos os SLAs ativos com seus tempos e configurações

-
-
+ +
+
+ + + +
+

Prorrogar prazo

+

Recurso exclusivo para a equipe de TI

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ {#if prorrogacaoFeedback} +
+ {prorrogacaoFeedback} +
+ {/if} + +
+
+
+
+ {/if} + {/if} - {#if slaConfigsQuery === undefined || slaConfigsQuery === null || ('data' in slaConfigsQuery && slaConfigsQuery.data === undefined)} -
- -
- {:else} - {@const slaConfigs = ('data' in slaConfigsQuery && slaConfigsQuery.data !== undefined) - ? (Array.isArray(slaConfigsQuery.data) ? slaConfigsQuery.data : []) - : (Array.isArray(slaConfigsQuery) ? slaConfigsQuery : [])} - {@const slaConfigsAtivos = slaConfigs.filter((s: SlaConfig) => s.ativo)} - {@const slaConfigsPorPrioridadeCount = { - baixa: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'baixa').length, - media: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'media').length, - alta: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'alta').length, - critica: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'critica').length, - }} - - {#if slaConfigsAtivos.length === 0} -
-

Nenhum SLA configurado

-

Configure SLAs para cada prioridade na seção abaixo

-
- {:else} - -
-
-
{slaConfigsAtivos.length}
-
Total de SLAs
-
-
-
{slaConfigsPorPrioridadeCount.baixa}
-
Prioridade Baixa
-
-
-
{slaConfigsPorPrioridadeCount.media}
-
Prioridade Média
-
-
-
{slaConfigsPorPrioridadeCount.alta}
-
Prioridade Alta
-
-
-
{slaConfigsPorPrioridadeCount.critica}
-
Prioridade Crítica
-
-
+ + {#if abaAtiva === 'sla'} + +
+
+
+

SLAs Configurados

+

+ Visualize todos os SLAs ativos com seus tempos e configurações +

+
+
- -
- - - - - - - - - - - - - - - {#each slaConfigsAtivos as sla (sla._id)} - - - - - - - - - - - {/each} - -
NomePrioridadeTempo de RespostaTempo de ConclusãoAuto-encerramentoAlerta AntecedênciaStatusAções
-
{sla.nome}
- {#if sla.descricao} -
{sla.descricao}
- {/if} -
- - {sla.prioridade} - - -
- {sla.tempoRespostaHoras}h - {#if sla.tempoRespostaHoras >= 24} - - ({Math.floor(sla.tempoRespostaHoras / 24)}d {sla.tempoRespostaHoras % 24}h) - - {/if} -
-
-
- {sla.tempoConclusaoHoras}h - {#if sla.tempoConclusaoHoras >= 24} - - ({Math.floor(sla.tempoConclusaoHoras / 24)}d {sla.tempoConclusaoHoras % 24}h) - - {/if} -
-
- {#if sla.tempoEncerramentoHoras} -
- {sla.tempoEncerramentoHoras}h - {#if sla.tempoEncerramentoHoras >= 24} - - ({Math.floor(sla.tempoEncerramentoHoras / 24)}d {sla.tempoEncerramentoHoras % 24}h) - - {/if} -
- {:else} - Não configurado - {/if} -
-
- {sla.alertaAntecedenciaHoras}h - antes -
-
- - - Ativo - - -
- - -
-
-
- {/if} - {/if} -
+ {#if slaConfigsQuery === undefined || slaConfigsQuery === null || ('data' in slaConfigsQuery && slaConfigsQuery.data === undefined)} +
+ +
+ {:else} + {@const slaConfigs = + 'data' in slaConfigsQuery && slaConfigsQuery.data !== undefined + ? Array.isArray(slaConfigsQuery.data) + ? slaConfigsQuery.data + : [] + : Array.isArray(slaConfigsQuery) + ? slaConfigsQuery + : []} + {@const slaConfigsAtivos = slaConfigs.filter((s: SlaConfig) => s.ativo)} + {@const slaConfigsPorPrioridadeCount = { + baixa: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'baixa').length, + media: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'media').length, + alta: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'alta').length, + critica: slaConfigsAtivos.filter((s: SlaConfig) => s.prioridade === 'critica').length + }} - -
-
-

Configuração de SLA por Prioridade

-

Configure SLAs separados para cada nível de prioridade

-
+ {#if slaConfigsAtivos.length === 0} +
+

Nenhum SLA configurado

+

+ Configure SLAs para cada prioridade na seção abaixo +

+
+ {:else} + +
+
+
{slaConfigsAtivos.length}
+
Total de SLAs
+
+
+
+ {slaConfigsPorPrioridadeCount.baixa} +
+
Prioridade Baixa
+
+
+
{slaConfigsPorPrioridadeCount.media}
+
Prioridade Média
+
+
+
+ {slaConfigsPorPrioridadeCount.alta} +
+
Prioridade Alta
+
+
+
+ {slaConfigsPorPrioridadeCount.critica} +
+
Prioridade Crítica
+
+
- -
- {#each ["baixa", "media", "alta", "critica"] as prioridade} - {@const slaAtual = slaConfigsPorPrioridade[prioridade]} -
-
-

{prioridade}

- {#if slaAtual} - Configurado - {:else} - Não configurado - {/if} -
- {#if slaAtual} -
-
- Resposta: - {slaAtual.tempoRespostaHoras}h -
-
- Conclusão: - {slaAtual.tempoConclusaoHoras}h -
- {#if slaAtual.tempoEncerramentoHoras} -
- Auto-encerramento: - {slaAtual.tempoEncerramentoHoras}h -
- {/if} -
- Alerta: - {slaAtual.alertaAntecedenciaHoras}h antes -
-
-
- - -
- {:else} - - {/if} -
- {/each} -
+ +
+ + + + + + + + + + + + + + + {#each slaConfigsAtivos as sla (sla._id)} + + + + + + + + + + + {/each} + +
NomePrioridadeTempo de RespostaTempo de ConclusãoAuto-encerramentoAlerta AntecedênciaStatusAções
+
{sla.nome}
+ {#if sla.descricao} +
+ {sla.descricao} +
+ {/if} +
+ + {sla.prioridade} + + +
+ {sla.tempoRespostaHoras}h + {#if sla.tempoRespostaHoras >= 24} + + ({Math.floor(sla.tempoRespostaHoras / 24)}d {sla.tempoRespostaHoras % + 24}h) + + {/if} +
+
+
+ {sla.tempoConclusaoHoras}h + {#if sla.tempoConclusaoHoras >= 24} + + ({Math.floor(sla.tempoConclusaoHoras / 24)}d {sla.tempoConclusaoHoras % + 24}h) + + {/if} +
+
+ {#if sla.tempoEncerramentoHoras} +
+ {sla.tempoEncerramentoHoras}h + {#if sla.tempoEncerramentoHoras >= 24} + + ({Math.floor(sla.tempoEncerramentoHoras / 24)}d {sla.tempoEncerramentoHoras % + 24}h) + + {/if} +
+ {:else} + Não configurado + {/if} +
+
+ {sla.alertaAntecedenciaHoras}h + antes +
+
+ + + Ativo + + +
+ + +
+
+
+ {/if} + {/if} +
- -
-

- {slaForm.slaId ? "Editar" : "Novo"} SLA - Prioridade {slaForm.prioridade.charAt(0).toUpperCase() + slaForm.prioridade.slice(1)} -

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- {#if slaFeedback} -
-

{slaFeedback}

-
- {/if} -
- - {#if slaForm.slaId} - - {/if} -
-
- - {/if} + +
+
+

+ Configuração de SLA por Prioridade +

+

+ Configure SLAs separados para cada nível de prioridade +

+
- - {#if slaParaExcluir} - - {/if} + +
+ {#each ['baixa', 'media', 'alta', 'critica'] as prioridade} + {@const slaAtual = slaConfigsPorPrioridade[prioridade]} +
+
+

{prioridade}

+ {#if slaAtual} + Configurado + {:else} + Não configurado + {/if} +
+ {#if slaAtual} +
+
+ Resposta: + {slaAtual.tempoRespostaHoras}h +
+
+ Conclusão: + {slaAtual.tempoConclusaoHoras}h +
+ {#if slaAtual.tempoEncerramentoHoras} +
+ Auto-encerramento: + {slaAtual.tempoEncerramentoHoras}h +
+ {/if} +
+ Alerta: + {slaAtual.alertaAntecedenciaHoras}h antes +
+
+
+ + +
+ {:else} + + {/if} +
+ {/each} +
-
+ +
+

+ {slaForm.slaId ? 'Editar' : 'Novo'} SLA - Prioridade {slaForm.prioridade + .charAt(0) + .toUpperCase() + slaForm.prioridade.slice(1)} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {#if slaFeedback} +
+

+ {slaFeedback} +

+
+ {/if} +
+ + {#if slaForm.slaId} + + {/if} +
+
+ + {/if} + + + {#if slaParaExcluir} + + {/if} +
- diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index ffaa5e2..c78b1cc 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -1147,27 +1147,27 @@
- +
-
+
- - - -
-
-

Notificações e Mensagens

+ fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + + +
+
+

Notificações e Mensagens

Envie avisos importantes por chat e email HTML padronizado para os usuários do SGSE. @@ -1186,11 +1186,11 @@

{totalTemplates}
-
+
- + {#if mensagem}
-
- -
-
-
- - - -
-
-

Gerenciar Templates

-

Criar, editar e excluir templates de emails e mensagens

-
-
- - - - - Voltar - -
- - - {#if mensagem} -
- {mensagem.texto} - -
- {/if} - - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-

Templates ({templatesFiltrados.length})

- +
+
+ +
+
+ - - {#if templatesFiltrados.length === 0} -
-

Nenhum template encontrado.

- {:else} -
- - - - - - - - - - - - - {#each templatesFiltrados as template (template._id)} +
+

Gerenciar Templates

+

+ Crie, edite e organize templates de chat (texto puro) e + email HTML padronizado usados em todas as notificações do SGSE. +

+
+ + + + + + Voltar para Notificações + + + + + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+

Templates ({templatesFiltrados.length})

+ + + + + Novo Template + +
+ + {#if templatesFiltrados.length === 0} +
+

Nenhum template encontrado.

+
+ {:else} +
+
CódigoNomeTipoCategoriaVariáveisAções
+ - - - - - - + + + + + + + + + {#each templatesFiltrados as template (template._id)} + + + + + + + - - {/each} - -
- {template.codigo} - -
{template.nome}
-
{template.titulo}
-
- {#if template.tipo === 'sistema'} - Sistema - {:else} - Customizado - {/if} - - {#if template.categoria} - {template.categoria} - {:else} - - - {/if} - - {#if template.variaveis && template.variaveis.length > 0} -
- {#each template.variaveis.slice(0, 3) as variavel} - {{variavel}} - {/each} - {#if template.variaveis.length > 3} - +{template.variaveis.length - 3} - {/if} -
- {:else} - - - {/if} -
-
- - - - - - {#if template.tipo === 'customizado'} -
CódigoNomeTipoCategoriaVariáveisAções
+ {template.codigo} + +
{template.nome}
+
{template.titulo}
+
+ {#if template.tipo === 'sistema'} + Sistema + {:else} + Customizado + {/if} + + {#if template.categoria} + {template.categoria} + {:else} + - + {/if} + + {#if template.variaveis && template.variaveis.length > 0} +
+ {#each template.variaveis.slice(0, 3) as variavel} + {{variavel}} + {/each} + {#if template.variaveis.length > 3} + +{template.variaveis.length - 3} + {/if} +
+ {:else} + - + {/if} +
+ -
-
- {/if} + + {#if template.tipo === 'customizado'} + + {/if} +
+
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte index 0c456fa..2533506 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte @@ -110,63 +110,66 @@ } -
-
-
-
- - - -
-
-

Editar Template

-

- Atualize as informações do template selecionado. Templates de sistema não podem ser - editados. -

+
+
+
+
+
+ + + +
+
+

Editar Template

+

+ Ajuste o texto base usado em chat e na versão HTML de + email. Templates de sistema podem ter restrições de edição. +

+
+ Voltar
- Voltar -
- {#if !template} -
- Template não encontrado. -
- {:else} - {#if mensagem} -
- {mensagem.texto} - + {#if !template} +
+ Template não encontrado.
- {/if} + {:else} + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} -
-
+
+
-
- {/if} + {/if} +
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte index ef98c0a..4c32f13 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte @@ -79,57 +79,61 @@ } -
-
-
-
- - - -
-
-

Novo Template

-

- Crie um template de email ou mensagem para reutilizar no sistema. -

+
+
+
+
+
+ + + +
+
+

Novo Template

+

+ Defina o texto base que será usado em chat e na versão + HTML de email com o estilo padrão do SGSE. +

+
+ Voltar
- Voltar -
- {#if mensagem} -
- {mensagem.texto} - -
- {/if} + {mensagem.texto} + +
+ {/if} -
-
+
+
{#if currentUser.data} + + + - - {#if avatarUrlDoUsuario()} - {currentUser.data?.nome - {:else} - - - {/if} + + {#if avatarUrlDoUsuario()} + {currentUser.data?.nome + {:else} + + + {/if} - -
- - +
+ + +
- -
diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index ff1113a..23902f3 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -424,15 +424,16 @@
{#each featureCards as card (card.title)} - + {/if} {/each}
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index c78b1cc..02672b5 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -21,6 +21,7 @@ enviadoPor: Id<'usuarios'>; criadoEm: number; enviadoEm: number | undefined; + erroDetalhes?: string; destinatarioInfo: Doc<'usuarios'> | null; templateInfo: Doc<'templatesMensagens'> | null; } @@ -55,10 +56,11 @@ const emailIdsArray = $derived( Array.from(emailIdsRastreados).map((id) => id as Id<'notificacoesEmail'>) ); - // Usar função para evitar execução quando array está vazio - const emailsStatusQuery = useQuery(api.email.buscarEmailsPorIds, () => + // Usar $derived para calcular argumentos da query condicionalmente + const emailsStatusArgs = $derived( emailIdsArray.length === 0 ? 'skip' : { emailIds: emailIdsArray } ); + const emailsStatusQuery = useQuery(api.email.buscarEmailsPorIds, emailsStatusArgs); // Queries para agendamentos const agendamentosEmailQuery = useQuery(api.email.listarAgendamentosEmail, {}); @@ -248,9 +250,21 @@ variaveis: Record ): string { const textoComVariaveis = renderizarTemplate(template, variaveis); - // Remove quaisquer tags HTML que possam ter sido inseridas por engano - const textoPuro = textoComVariaveis.replace(/<[^>]*>/g, ''); - return textoPuro; + // Remove todas as tags HTML (incluindo quebras de linha HTML) + let textoPuro = textoComVariaveis.replace(/<[^>]*>/g, ''); + // Converte entidades HTML comuns para texto normal + textoPuro = textoPuro + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&[a-zA-Z0-9#]+;/g, ''); // Remove outras entidades HTML + // Normaliza espaços múltiplos (mas preserva quebras de linha reais) + textoPuro = textoPuro.replace(/[ \t]+/g, ' ').replace(/[ \t]*\n[ \t]*/g, '\n'); + return textoPuro.trim(); } // Função para mostrar mensagens @@ -726,9 +740,10 @@ }); if (conversaId) { + // Para chat, sempre remover HTML dos templates const mensagem = usarTemplate && templateSelecionado - ? renderizarTemplate(templateSelecionado.corpo, { + ? renderizarTemplateChatLocal(templateSelecionado.corpo, { nome: destinatario.nome, matricula: destinatario.matricula || '' }) @@ -1999,6 +2014,7 @@ Para enviar emails, certifique-se de configurar o SMTP em Configurações de Email.
+
diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index dee978b..eaed2b3 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -402,18 +402,13 @@ export const buscarEmailsPorIds = query({ export const listarAgendamentosEmail = query({ args: {}, handler: async (ctx) => { - // Buscar todos os emails agendados (pendentes ou enviando) + // Buscar todos os emails agendados (pendentes, enviando ou já enviados que tinham agendamento) const emailsAgendados = await ctx.db .query("notificacoesEmail") .filter((q) => { - const temAgendamento = q.neq(q.field("agendadaPara"), undefined); - const statusValido = q.or( - q.eq(q.field("status"), "pendente"), - q.eq(q.field("status"), "enviando") - ); - return q.and(temAgendamento, statusValido); + // Apenas emails que têm agendadaPara definido + return q.neq(q.field("agendadaPara"), undefined); }) - .order("asc") .collect(); // Enriquecer com informações de destinatário e template From 6e836e9eb5b0bf8a674947c647e4ef654b9b0602 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 19:54:33 -0300 Subject: [PATCH 31/31] feat: implement template retrieval by ID and enhance error handling in template display for improved user experience --- .../ti/notificacoes/templates/+page.svelte | 5 +- .../notificacoes/templates/[id]/+page.svelte | 73 ++++++++++++++----- packages/backend/convex/templatesMensagens.ts | 13 ++++ 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte index 2869d8a..ed24f76 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte @@ -3,6 +3,7 @@ import { api } from '@sgse-app/backend/convex/_generated/api'; import type { FunctionReference } from 'convex/server'; import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; + import { resolve } from '$app/paths'; const client = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>); @@ -191,7 +192,7 @@

Templates ({templatesFiltrados.length})

- +
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte index 2533506..071b4ce 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte @@ -3,29 +3,51 @@ import { useConvexClient, useQuery } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; import type { FunctionReference } from 'convex/server'; - import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel'; + import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; const client = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>); - const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {}); - - const params = $derived($page.params); - const templateIdParam = $derived(params.id ?? ''); - - const templates = $derived.by(() => { - if (templatesQuery === undefined || templatesQuery === null) return []; - if ('data' in templatesQuery && templatesQuery.data !== undefined) { - return Array.isArray(templatesQuery.data) ? templatesQuery.data : []; + + const templateIdParam = $derived($page.params.id ?? ''); + + // Query específica para buscar o template por ID + const templateQuery = useQuery( + api.templatesMensagens.obterTemplatePorId, + () => { + if (!templateIdParam) return 'skip'; + // Validar se o ID tem o formato correto do Convex + if (typeof templateIdParam === 'string' && templateIdParam.length > 0) { + return { templateId: templateIdParam as Id<'templatesMensagens'> }; + } + return 'skip'; } - if (Array.isArray(templatesQuery)) return templatesQuery; - return []; + ); + + // Extrair template da query + const template = $derived.by(() => { + if (templateQuery === undefined || templateQuery === null) return null; + // useQuery retorna os dados diretamente + if (templateQuery && typeof templateQuery === 'object') { + // Se tem propriedade data, usar ela + if ('data' in templateQuery && templateQuery.data !== undefined && templateQuery.data !== null) { + return templateQuery.data as Doc<'templatesMensagens'> | null; + } + // Caso contrário, assumir que é o próprio template + if (!('data' in templateQuery)) { + return templateQuery as Doc<'templatesMensagens'> | null; + } + } + return null; }); - const template = $derived.by(() => { - const lista = templates as Doc<'templatesMensagens'>[]; - return lista.find((t) => String(t._id) === templateIdParam) ?? null; + const carregandoTemplate = $derived(templateQuery === undefined || templateQuery === null); + const erroTemplate = $derived.by(() => { + if (templateQuery && typeof templateQuery === 'object' && 'error' in templateQuery) { + return templateQuery.error as Error | string | null; + } + return null; }); let nome = $state(''); @@ -143,9 +165,25 @@ Voltar
- {#if !template} + {#if carregandoTemplate} +
+ +

Carregando template...

+
+ {:else if erroTemplate}
- Template não encontrado. + Erro ao carregar template: {typeof erroTemplate === 'string' ? erroTemplate : erroTemplate?.message || 'Erro desconhecido'} + + Voltar para Templates + +
+ {:else if !template} +
+ Template não encontrado. Verifique se o ID está correto. +
ID: {templateIdParam}
+ + Voltar para Templates +
{:else} {#if mensagem} @@ -279,6 +317,7 @@ {/if}
+
{/if}
diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index d6a0387..78c24de 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -32,6 +32,19 @@ export const obterTemplatePorCodigo = query({ }, }); +/** + * Obter template por ID + */ +export const obterTemplatePorId = query({ + args: { + templateId: v.id("templatesMensagens"), + }, + handler: async (ctx, args) => { + const template = await ctx.db.get(args.templateId); + return template; + }, +}); + /** * Criar template customizado (apenas TI_MASTER) */