From 501751c22f98b4c0b2a2d4281ccb61800044a369 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 28 Nov 2025 16:50:45 -0300 Subject: [PATCH 001/138] 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, }; }) ); -- 2.49.1 From 1d9f924cb825a438d018af7c82533db650a15ba7 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 29 Nov 2025 20:30:35 -0300 Subject: [PATCH 009/138] 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, }; -- 2.49.1 From 545e119367e994c327885d1244086a79e16fa6f1 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 29 Nov 2025 22:27:23 -0300 Subject: [PATCH 010/138] 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 -- 2.49.1 From 298326e264709affb9755733e2efaf8b0415a5a5 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 29 Nov 2025 23:25:14 -0300 Subject: [PATCH 011/138] 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}
-- 2.49.1 From b85021d924d4f2eaaacd64ed829c6194a0f817ac Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 00:30:38 -0300 Subject: [PATCH 012/138] 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}
-- 2.49.1 From 39c948aa6b181d7359f53c0cb68f0345c6279e25 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 00:35:20 -0300 Subject: [PATCH 013/138] 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}
- -
- - - - + -- 2.49.1 From e35846103e590f09f277d4a77dae8aee8d7455f2 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 00:43:17 -0300 Subject: [PATCH 015/138] 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 016/138] 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"]) -- 2.49.1 From f1c2ae0e6b0125d7e3ed6b7bc3ce12e4e5a7ab4b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 08:42:21 -0300 Subject: [PATCH 017/138] 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; }, }); -- 2.49.1 From 3204440a38d60b7708e6358c7e490b22e1118404 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 15:32:21 -0300 Subject: [PATCH 018/138] 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"]) -- 2.49.1 From e43f9fcf14167608f75842b2ecf05843c88c360e Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 15:40:58 -0300 Subject: [PATCH 019/138] 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} -- 2.49.1 From 268510bbf2322c545f76322027464898c065f23d Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 15:55:48 -0300 Subject: [PATCH 022/138] 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

-- 2.49.1 From 2fb7df8849bc846676937528f4c38475bc5d38ca Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 16:00:31 -0300 Subject: [PATCH 023/138] =?UTF-8?q?feat:=20implement=20reactive=20event=20?= =?UTF-8?q?query=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} -- 2.49.1 From 4ab151bed73b5e4b3bcc1dd932aa6400315672f7 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 16:33:52 -0300 Subject: [PATCH 024/138] =?UTF-8?q?feat:=20add=20tab=20navigation=20and=20?= =?UTF-8?q?content=20management=20for=20notifications=20page,=20allowing?= =?UTF-8?q?=20users=20to=20switch=20between=20Enviar=20Notifica=C3=A7?= =?UTF-8?q?=C3=A3o,=20Gerenciar=20Templates,=20and=20Agendamentos=20for=20?= =?UTF-8?q?improved=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; +} + -- 2.49.1 From 4e3feca84de04153d7e2c71b978195da28df8c06 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 30 Nov 2025 16:47:48 -0300 Subject: [PATCH 025/138] 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} -- 2.49.1 From d9e78079c8c905e681b5778f88d2810e49cb4344 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 05:45:19 -0300 Subject: [PATCH 026/138] 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, -- 2.49.1 From 4c2d12f44383f1d1adda856e8181938e1a9087a2 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 09:50:53 -0300 Subject: [PATCH 027/138] 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", -- 2.49.1 From 4af566e54cc2726e14dcc256bc007a1dbd5e8d93 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 09:54:34 -0300 Subject: [PATCH 028/138] 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 029/138] 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 -- 2.49.1 From b8a67e0a57d1bb96c167bb941dd5e4dd1470732f Mon Sep 17 00:00:00 2001 From: killer-cf Date: Mon, 1 Dec 2025 17:11:34 -0300 Subject: [PATCH 031/138] feat: Implement initial pedido (order) management, product catalog, and TI configuration features. --- .agent/rules/convex-svelte-best-practices.md | 127 + apps/web/src/lib/utils/masks.ts | 252 +- .../routes/(dashboard)/compras/+page.svelte | 46 +- .../(dashboard)/compras/produtos/+page.svelte | Bin 0 -> 7114 bytes .../routes/(dashboard)/pedidos/+page.svelte | 157 + .../(dashboard)/pedidos/[id]/+page.svelte | 609 +++ .../(dashboard)/pedidos/novo/+page.svelte | 358 ++ .../programas-esportivos/+page.svelte | 19 +- .../programas-esportivos/acoes/+page.svelte | 210 + .../src/routes/(dashboard)/ti/+page.svelte | 9 + .../(dashboard)/ti/configuracoes/+page.svelte | 94 + packages/backend/convex/_generated/api.d.ts | 8 + packages/backend/convex/acoes.ts | 56 + packages/backend/convex/config.ts | 38 + packages/backend/convex/pedidos.ts | 596 +++ packages/backend/convex/permissoesAcoes.ts | 94 + packages/backend/convex/produtos.ts | 69 + packages/backend/convex/schema.ts | 3621 +++++++++-------- 18 files changed, 4429 insertions(+), 1934 deletions(-) create mode 100644 .agent/rules/convex-svelte-best-practices.md create mode 100644 apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/pedidos/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte create mode 100644 packages/backend/convex/acoes.ts create mode 100644 packages/backend/convex/config.ts create mode 100644 packages/backend/convex/pedidos.ts create mode 100644 packages/backend/convex/produtos.ts diff --git a/.agent/rules/convex-svelte-best-practices.md b/.agent/rules/convex-svelte-best-practices.md new file mode 100644 index 0000000..9c834df --- /dev/null +++ b/.agent/rules/convex-svelte-best-practices.md @@ -0,0 +1,127 @@ +--- +trigger: glob +globs: **/*.svelte.ts,**/*.svelte +--- + +# Convex + Svelte Best Practices + +This document outlines the mandatory rules and best practices for integrating Convex with Svelte in this project. + +## 1. Imports + +Always use the following import paths. Do NOT use `$lib/convex` or relative paths for generated files unless specifically required by a local override. + +### Correct Imports: + +```typescript +import { useQuery, useConvexClient } from 'convex-svelte'; +import { api } from '@sgse-app/backend/convex/_generated/api'; +import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; +``` + +### Incorrect Imports (Avoid): + +```typescript +import { convex } from '$lib/convex'; // Avoid direct client usage for queries +import { api } from '$lib/convex/_generated/api'; // Incorrect path +import { api } from '../convex/_generated/api'; // Relative path +``` + +## 2. Data Fetching + +### Use `useQuery` for Reactivity + +Instead of manually fetching data inside `onMount`, use the `useQuery` hook. This ensures your data is reactive and automatically updates when the backend data changes. + +**Preferred Pattern:** + +```svelte + +``` + +**Avoid Pattern:** + +```svelte + +``` + +### Mutations + +Use `useConvexClient` to access the client for mutations. + +```svelte + +``` + +## 3. Type Safety + +### No `any` + +Strictly avoid using `any`. The Convex generated data model provides precise types for all your tables. + +### Use Generated Types + +Use `Doc<"tableName">` for full document objects and `Id<"tableName">` for IDs. + +**Correct:** + +```typescript +import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; + +let selectedTask: Doc<'tasks'> | null = $state(null); +let taskId: Id<'tasks'>; +``` + +**Incorrect:** + +```typescript +let selectedTask: any = $state(null); +let taskId: string; +``` + +### Union Types for Enums + +When dealing with status fields or other enums, define the specific union type instead of casting to `any`. + +**Correct:** + +```typescript +async function updateStatus(newStatus: 'pending' | 'completed' | 'archived') { + // ... +} +``` + +**Incorrect:** + +```typescript +async function updateStatus(newStatus: string) { + // ... + status: newStatus as any; // Avoid this +} +``` diff --git a/apps/web/src/lib/utils/masks.ts b/apps/web/src/lib/utils/masks.ts index d418b61..8b5efbd 100644 --- a/apps/web/src/lib/utils/masks.ts +++ b/apps/web/src/lib/utils/masks.ts @@ -2,185 +2,191 @@ /** Remove all non-digit characters from string */ export const onlyDigits = (value: string): string => { - return (value || "").replace(/\D/g, ""); + return (value || '').replace(/\D/g, ''); }; /** Format CPF: 000.000.000-00 */ export const maskCPF = (value: string): string => { - const digits = onlyDigits(value).slice(0, 11); - return digits - .replace(/(\d{3})(\d)/, "$1.$2") - .replace(/(\d{3})(\d)/, "$1.$2") - .replace(/(\d{3})(\d{1,2})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 11); + return digits + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d{1,2})$/, '$1-$2'); }; /** Validate CPF format and checksum */ export const validateCPF = (value: string): boolean => { - const digits = onlyDigits(value); - - if (digits.length !== 11 || /^([0-9])\1+$/.test(digits)) { - return false; - } - - const calculateDigit = (base: string, factor: number): number => { - let sum = 0; - for (let i = 0; i < base.length; i++) { - sum += parseInt(base[i]) * (factor - i); - } - const rest = (sum * 10) % 11; - return rest === 10 ? 0 : rest; - }; - - const digit1 = calculateDigit(digits.slice(0, 9), 10); - const digit2 = calculateDigit(digits.slice(0, 10), 11); - - return digits[9] === String(digit1) && digits[10] === String(digit2); + const digits = onlyDigits(value); + + if (digits.length !== 11 || /^([0-9])\1+$/.test(digits)) { + return false; + } + + const calculateDigit = (base: string, factor: number): number => { + let sum = 0; + for (let i = 0; i < base.length; i++) { + sum += parseInt(base[i]) * (factor - i); + } + const rest = (sum * 10) % 11; + return rest === 10 ? 0 : rest; + }; + + const digit1 = calculateDigit(digits.slice(0, 9), 10); + const digit2 = calculateDigit(digits.slice(0, 10), 11); + + return digits[9] === String(digit1) && digits[10] === String(digit2); }; /** Format CEP: 00000-000 */ export const maskCEP = (value: string): string => { - const digits = onlyDigits(value).slice(0, 8); - return digits.replace(/(\d{5})(\d{1,3})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 8); + return digits.replace(/(\d{5})(\d{1,3})$/, '$1-$2'); }; /** Format CNPJ: 00.000.000/0000-00 */ export const maskCNPJ = (value: string): string => { - const digits = onlyDigits(value).slice(0, 14); - return digits - .replace(/(\d{2})(\d)/, "$1.$2") - .replace(/(\d{3})(\d)/, "$1.$2") - .replace(/(\d{3})(\d)/, "$1/$2") - .replace(/(\d{4})(\d{1,2})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 14); + return digits + .replace(/(\d{2})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1.$2') + .replace(/(\d{3})(\d)/, '$1/$2') + .replace(/(\d{4})(\d{1,2})$/, '$1-$2'); }; /** Format phone: (00) 0000-0000 or (00) 00000-0000 */ export const maskPhone = (value: string): string => { - const digits = onlyDigits(value).slice(0, 11); - - if (digits.length <= 10) { - return digits - .replace(/(\d{2})(\d)/, "($1) $2") - .replace(/(\d{4})(\d{1,4})$/, "$1-$2"); - } - - return digits - .replace(/(\d{2})(\d)/, "($1) $2") - .replace(/(\d{5})(\d{1,4})$/, "$1-$2"); + const digits = onlyDigits(value).slice(0, 11); + + if (digits.length <= 10) { + return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d{1,4})$/, '$1-$2'); + } + + return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d{1,4})$/, '$1-$2'); }; /** Format date: dd/mm/aaaa */ export const maskDate = (value: string): string => { - const digits = onlyDigits(value).slice(0, 8); - return digits - .replace(/(\d{2})(\d)/, "$1/$2") - .replace(/(\d{2})(\d{1,4})$/, "$1/$2"); + const digits = onlyDigits(value).slice(0, 8); + return digits.replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d{1,4})$/, '$1/$2'); }; /** Validate date in format dd/mm/aaaa */ export const validateDate = (value: string): boolean => { - const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); - if (!match) return false; - - const day = Number(match[1]); - const month = Number(match[2]) - 1; - const year = Number(match[3]); - - const date = new Date(year, month, day); - - return ( - date.getFullYear() === year && - date.getMonth() === month && - date.getDate() === day - ); + const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!match) return false; + + const day = Number(match[1]); + const month = Number(match[2]) - 1; + const year = Number(match[3]); + + const date = new Date(year, month, day); + + return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day; }; /** Format UF: uppercase, max 2 chars */ export const maskUF = (value: string): string => { - return (value || "").toUpperCase().replace(/[^A-Z]/g, "").slice(0, 2); + return (value || '') + .toUpperCase() + .replace(/[^A-Z]/g, '') + .slice(0, 2); }; /** Format RG by UF */ const rgFormatByUF: Record = { - RJ: [2, 3, 2, 1], - SP: [2, 3, 3, 1], - MG: [2, 3, 3, 1], - ES: [2, 3, 3, 1], - PR: [2, 3, 3, 1], - SC: [2, 3, 3, 1], - RS: [2, 3, 3, 1], - BA: [2, 3, 3, 1], - PE: [2, 3, 3, 1], - CE: [2, 3, 3, 1], - PA: [2, 3, 3, 1], - AM: [2, 3, 3, 1], - AC: [2, 3, 3, 1], - AP: [2, 3, 3, 1], - AL: [2, 3, 3, 1], - RN: [2, 3, 3, 1], - PB: [2, 3, 3, 1], - MA: [2, 3, 3, 1], - PI: [2, 3, 3, 1], - DF: [2, 3, 3, 1], - GO: [2, 3, 3, 1], - MT: [2, 3, 3, 1], - MS: [2, 3, 3, 1], - RO: [2, 3, 3, 1], - RR: [2, 3, 3, 1], - TO: [2, 3, 3, 1], + RJ: [2, 3, 2, 1], + SP: [2, 3, 3, 1], + MG: [2, 3, 3, 1], + ES: [2, 3, 3, 1], + PR: [2, 3, 3, 1], + SC: [2, 3, 3, 1], + RS: [2, 3, 3, 1], + BA: [2, 3, 3, 1], + PE: [2, 3, 3, 1], + CE: [2, 3, 3, 1], + PA: [2, 3, 3, 1], + AM: [2, 3, 3, 1], + AC: [2, 3, 3, 1], + AP: [2, 3, 3, 1], + AL: [2, 3, 3, 1], + RN: [2, 3, 3, 1], + PB: [2, 3, 3, 1], + MA: [2, 3, 3, 1], + PI: [2, 3, 3, 1], + DF: [2, 3, 3, 1], + GO: [2, 3, 3, 1], + MT: [2, 3, 3, 1], + MS: [2, 3, 3, 1], + RO: [2, 3, 3, 1], + RR: [2, 3, 3, 1], + TO: [2, 3, 3, 1] }; export const maskRGByUF = (uf: string, value: string): string => { - const raw = (value || "").toUpperCase().replace(/[^0-9X]/g, ""); - const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; - const baseMax = a + b + c; - const baseDigits = raw.replace(/X/g, "").slice(0, baseMax); - const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1); - - const g1 = baseDigits.slice(0, a); - const g2 = baseDigits.slice(a, a + b); - const g3 = baseDigits.slice(a + b, a + b + c); - - let formatted = g1; - if (g2) formatted += `.${g2}`; - if (g3) formatted += `.${g3}`; - if (verifier) formatted += `-${verifier}`; - - return formatted; + const raw = (value || '').toUpperCase().replace(/[^0-9X]/g, ''); + const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; + const baseMax = a + b + c; + const baseDigits = raw.replace(/X/g, '').slice(0, baseMax); + const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1); + + const g1 = baseDigits.slice(0, a); + const g2 = baseDigits.slice(a, a + b); + const g3 = baseDigits.slice(a + b, a + b + c); + + let formatted = g1; + if (g2) formatted += `.${g2}`; + if (g3) formatted += `.${g3}`; + if (verifier) formatted += `-${verifier}`; + + return formatted; }; export const padRGLeftByUF = (uf: string, value: string): string => { - const raw = (value || "").toUpperCase().replace(/[^0-9X]/g, ""); - const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; - const baseMax = a + b + c; - let base = raw.replace(/X/g, ""); - const verifier = raw.slice(base.length, base.length + dv).slice(0, 1); - - if (base.length < baseMax) { - base = base.padStart(baseMax, "0"); - } - - return maskRGByUF(uf, base + (verifier || "")); + const raw = (value || '').toUpperCase().replace(/[^0-9X]/g, ''); + const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1]; + const baseMax = a + b + c; + let base = raw.replace(/X/g, ''); + const verifier = raw.slice(base.length, base.length + dv).slice(0, 1); + + if (base.length < baseMax) { + base = base.padStart(baseMax, '0'); + } + + return maskRGByUF(uf, base + (verifier || '')); }; /** Format account number */ export const maskContaBancaria = (value: string): string => { - const digits = onlyDigits(value); - return digits; + const digits = onlyDigits(value); + return digits; }; /** Format zone and section for voter title */ export const maskZonaSecao = (value: string): string => { - const digits = onlyDigits(value).slice(0, 4); - return digits; + const digits = onlyDigits(value).slice(0, 4); + return digits; }; /** Format general numeric field */ export const maskNumeric = (value: string): string => { - return onlyDigits(value); + return onlyDigits(value); +}; + +/** Format Brazilian currency (e.g. R$ 1.234,56) */ +export const maskCurrencyBRL = (value: string): string => { + const digits = onlyDigits(value); + if (!digits) return ''; + + const int = parseInt(digits, 10); + const amount = int / 100; + + return `R$ ${amount.toLocaleString('pt-BR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}`; }; /** Remove extra spaces and trim */ export const normalizeText = (value: string): string => { - return (value || "").replace(/\s+/g, " ").trim(); + return (value || '').replace(/\s+/g, ' ').trim(); }; - diff --git a/apps/web/src/routes/(dashboard)/compras/+page.svelte b/apps/web/src/routes/(dashboard)/compras/+page.svelte index 53bee00..b24031f 100644 --- a/apps/web/src/routes/(dashboard)/compras/+page.svelte +++ b/apps/web/src/routes/(dashboard)/compras/+page.svelte @@ -1,5 +1,5 @@ @@ -25,22 +25,40 @@
-
-
-
- diff --git a/apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte b/apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..9c273621fdb0b283b5b3ae6db0b465ceb9e3f3c3 GIT binary patch literal 7114 zcmdT}>uwvz74F~FQ%qR{lu#{6%d+FDB%M_D4+Dwe+C~weD1zbcklc9ol9^eGG|lJf z6XXf$JS9QtynO#ivN=PI6f9~w%O%bWS)C(A(3btca%UcZCK`vG`ViJ;}fD8 z4%s`wGgGVt`+>|Gwl%8GTT^Rmb7ptj$79%L5IzoMq0K4V?!e0*7d~Ojqg*KP&5y&H zWiTL{u+N{_-~X}SC2O7+)#C0=h5p)+?Dq(zYIXMx`}xJ76k79zpaZK`%Ahoy!X?uN z?mkMSFibD&E4K*2?fUVYOD*U;`Y!UqK*HDg1$HTt)iERi9Jyz2spqvSUy`2j>-H!~ zs=5>xtlvMy^NPz_z0#&Ad0vM_Q#ABkRJtweKI5A8wNR@f!^>yZ7dW{!t>X60QynGN z`c?;O>Ty5#s6RbnBx^Befy6B&(E?D)QfFrH zU6I2+Nz&1mHV0J$87WRm-%$HnJcknp2i76q_4_A|fiqEHOmD<@M^fyVfWxfx2f~tL zeoji~kQ=gv^p=m&0st8|1D)zLOCUBFKfvhJy8V-i;&YuVgS7gy^u_Y_uLa>mg@nO4zdlFRLP~ z4LQ12CdGFw<0e}|N3R=~LYd?KD-;aIYo@teafLi%e*_H)PG8hXs1=qqno3b|DZR;} zF{9UT^qpRMDUgohm<)7i6DJVa|M#fQ^Fo!!{STsK83YucIF(?Q3oX9qOsq567K$P1 zh08Znzv!QwFeOj|DpE8$^Z~w8qO4csJ67b(`QSb~c?s?_6xm@sbgg@ObTrP36`Bs$ zdeVcAh8O6ntX!uY?SwVyGwSMHqH>l#V{<9itT3X~X@)??j_+F($N46m33DYxMZ1#m z@^N5o#M-21Ysuy~Bc0VU?@izOo;4mWA6xQSYfN2HjMNqSVD@pcJw9QRX{QXH_z<%4 z-jrg&oAivSx~+1Nr)AD&i*zPikvb{;h9Z%SSSvoksxFi>{|++hkf8~zdsG1@m)DWOKP`Z zmk%-c;2HVgiSvOJbK{=Y+J#1m64jQ>D4;!b$Mg!KC`aPY8L_r$gvz)U>7_b?VANRu&G+w$gu)~7^KNz~lb7PC;fY#u@`U#gHC62_u@c})5j=|wj?eTy*?j}`W?!PVAR2$;U3y{ z2t7|*ZzD_lc8z0l8_S4~opJ1cCuGyAMvA?Z{{1>raCIu7TE*jS%^=EMtQD@hyHgHr zMZopV@T_Q56Yr$S{XE)btJsgNjcS`G~01uS5>pMJ$_P8BV`SKpN zpur2O2U1iEv$V-O3L$W#dexGGbJ9ESh03|FWL5(Y&Fph%o}Dy!BdR4@tv?hfJm+b#oS4SMXKDE2G0a2=-ES|WIH56kBH)BinbD>q!WDb%!{?i8A^aK z>4<$wpN<^FTslPJfyu3a(<>PP(8eKGi|BwMhUXnb*8xmQNL-mPM)AB$z6SmQVhkm? zi=3MDIVKJkJ*{PcLEW5*`Le&Q7ugFLZm8uG=u<{UM+lPz|Qz@K%4 z_rdtv07uVbdUOCN%sF13g;=mS-Man|b#M1~K$O>4n3HhTXQ-WoZ2P-_Z2`*92+R^w zy2LR|r|e~Rvh7}NcR{p{X>sy-Xwk`7@|lnwq0ZP$*4al_DngB-Ru)3-#oj|IyR$q8u)vck{8eEJaqTq^Gw!355dz#H^MHmyw z^Tb^bLa#M@KxF*^v3wXnI6B840I*Q)v1pNdv4dzdki>ixH=vk&Gt|fnOifzcEy5Mq zA&js|i63yC6d?`WUW0zl60Ws{llIe?8{I87aG<|9ccFWlt(Q*@hAwgP(<$^o z=KBdu=!4^=9m8k!)P^$ literal 0 HcmV?d00001 diff --git a/apps/web/src/routes/(dashboard)/pedidos/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/+page.svelte new file mode 100644 index 0000000..03419cb --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/+page.svelte @@ -0,0 +1,157 @@ + + +
+
+

Pedidos

+ + + Novo Pedido + +
+ + {#if loading} +

Carregando...

+ {:else if error} +

{error}

+ {:else} +
+ + + + + + + + + + + + {#each pedidos as pedido (pedido._id)} + + + + + + + + {/each} + {#if pedidos.length === 0} + + + + {/if} + +
Número SEIStatusAçãoData de CriaçãoAções
+ {#if pedido.numeroSei} + {pedido.numeroSei} + {:else} + Sem número SEI + {/if} + + + {formatStatus(pedido.status)} + + + {getAcaoNome(pedido.acaoId)} + + {formatDate(pedido.criadoEm)} + + + + Visualizar + +
Nenhum pedido cadastrado.
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte new file mode 100644 index 0000000..30caf9a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte @@ -0,0 +1,609 @@ + + +
+ {#if loading} +

Carregando...

+ {:else if error} +

{error}

+ {:else if pedido} +
+
+

+ {#if editingSei} +
+ + + +
+ {:else} +
+ Pedido {pedido.numeroSei || 'sem número SEI'} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} +
+ {/if} + + {formatStatus(pedido.status)} + +

+ {#if acao} +

Ação: {acao.nome} ({acao.tipo})

+ {/if} + {#if !pedido.numeroSei} +

+ ⚠️ Este pedido não possui número SEI. Adicione um número SEI quando disponível. +

+ {/if} +
+ +
+ {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} + + {#if pedido.status === 'aguardando_aceite'} + + + {/if} + + {#if pedido.status === 'em_analise'} + + + {/if} + + {#if pedido.status !== 'cancelado' && pedido.status !== 'concluido'} + + {/if} +
+
+ + +
+
+

Itens do Pedido

+ {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} +
+ + {#if showAddItem} +
+
+
+ + +
+
+ + +
+
+ + (newItem.valorEstimado = maskCurrencyBRL(e.currentTarget.value))} + class="w-full rounded-md border-gray-300 text-sm shadow-sm" + placeholder="R$ 0,00" + /> +
+
+ + +
+
+
+ {/if} + + + + + + + + + + + + + + {#each items as item (item._id)} + + + + + + + + + {/each} + {#if items.length === 0} + + + + {:else} + + + + + {/if} + +
ProdutoQuantidadeValor EstimadoAdicionado PorTotalAções
{getProductName(item.produtoId)} + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)} + class="w-20 rounded border px-2 py-1 text-sm" + /> + {:else} + {item.quantidade} + {/if} + + {maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'} + + {item.adicionadoPorNome} + + R$ {calculateItemTotal(item.valorEstimado, item.quantidade) + .toFixed(2) + .replace('.', ',')} + + {#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'} + + {/if} +
Nenhum item adicionado.
+ Total Geral: + + R$ {totalGeral.toFixed(2).replace('.', ',')} +
+
+ + +
+

Histórico

+
+ {#if history.length === 0} +

Nenhum histórico disponível.

+ {:else} + {#each history as entry (entry._id)} +
+
+ {getHistoryIcon(entry.acao)} +
+
+

+ {formatHistoryEntry(entry)} +

+

+ {new Date(entry.data).toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +

+
+
+ {/each} + {/if} +
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte new file mode 100644 index 0000000..6039953 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte @@ -0,0 +1,358 @@ + + +
+

Novo Pedido

+ +
+ {#if error} +
+ {error} +
+ {/if} + +
+
+ + +

+ Você pode adicionar o número SEI posteriormente, se necessário. +

+
+ +
+ + {#if loading} +

Carregando ações...

+ {:else} + + {/if} +
+ +
+ + +
+ +
+ + {#if searchQuery.length > 0} +
+ {#if searchResults === undefined} +

Carregando...

+ {:else if searchResults.length === 0} +

Nenhum produto encontrado.

+ {:else} +
    + {#each searchResults as produto (produto._id)} +
  • + +
  • + {/each} +
+ {/if} +
+ {/if} + + {#if selectedProdutos.length > 0} +
+

Produtos Selecionados:

+
    + {#each selectedProdutos as item (item.produto._id)} +
  • + {item.produto.nome} + +
    + + +
    +
  • + {/each} +
+
+ {/if} +
+ + {#if warning} +
+ {warning} +
+ {/if} + + {#if checking} +

Verificando pedidos existentes...

+ {/if} + + {#if existingPedidos.length > 0} +
+

+ Os pedidos abaixo estão em rascunho/análise. Você pode abri-los para adicionar itens. +

+
    + {#each existingPedidos as pedido (pedido._id)} +
  • +
    +
    +
    + Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)} +
    +
    + Ação: {getAcaoNome(pedido.acaoId)} +
    +
    + + Abrir pedido + +
    + + {#if getMatchingInfo(pedido)} +
    + {getMatchingInfo(pedido)} +
    + {/if} +
  • + {/each} +
+
+ {/if} + +
+ + Cancelar + + +
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte index d294ee3..4744222 100644 --- a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte @@ -1,5 +1,5 @@ @@ -43,6 +43,23 @@
+ +
+
+
+ +
+

Ações

+
+

+ Gerencie ações, projetos e leis relacionadas aos programas esportivos. +

+
+
+
diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte new file mode 100644 index 0000000..0bd5a59 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/programas-esportivos/acoes/+page.svelte @@ -0,0 +1,210 @@ + + +
+
+

Ações

+ +
+ + {#if loading} +

Carregando...

+ {:else if error} +

{error}

+ {:else} +
+ + + + + + + + + + {#each acoes as acao (acao._id)} + + + + + + {/each} + {#if acoes.length === 0} + + + + {/if} + +
NomeTipoAções
{acao.nome} + + {acao.tipo === 'projeto' ? 'Projeto' : 'Lei'} + + + + +
Nenhuma ação cadastrada.
+
+ {/if} + + {#if showModal} +
+
+ +

{editingId ? 'Editar' : 'Novo'} Ação

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ {/if} +
diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index faa37e5..84f40d8 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -367,6 +367,15 @@ palette: 'accent', icon: 'building' }, + { + title: 'Configurações Gerais', + description: + 'Configure opções gerais do sistema, incluindo setor de compras e outras configurações administrativas.', + ctaLabel: 'Configurar', + href: '/(dashboard)/ti/configuracoes', + palette: 'secondary', + icon: 'control' + }, { title: 'Documentação', description: diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte new file mode 100644 index 0000000..9c82ee7 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes/+page.svelte @@ -0,0 +1,94 @@ + + +
+

Configurações Gerais

+ + {#if loading} +

Carregando...

+ {:else} +
+

Setor de Compras

+

+ Selecione o setor responsável por receber e aprovar pedidos de compra. +

+ + {#if error} +
+ {error} +
+ {/if} + + {#if success} +
+ {success} +
+ {/if} + +
+ + +
+ + +
+ {/if} +
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 770c2b4..d5115b6 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as acoes from "../acoes.js"; import type * as actions_email from "../actions/email.js"; import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js"; @@ -21,6 +22,7 @@ import type * as auth_utils from "../auth/utils.js"; import type * as chamadas from "../chamadas.js"; import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; +import type * as config from "../config.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoJitsi from "../configuracaoJitsi.js"; import type * as configuracaoPonto from "../configuracaoPonto.js"; @@ -43,9 +45,11 @@ import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAtividades from "../logsAtividades.js"; import type * as logsLogin from "../logsLogin.js"; import type * as monitoramento from "../monitoramento.js"; +import type * as pedidos from "../pedidos.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; import type * as pontos from "../pontos.js"; import type * as preferenciasNotificacao from "../preferenciasNotificacao.js"; +import type * as produtos from "../produtos.js"; import type * as pushNotifications from "../pushNotifications.js"; import type * as roles from "../roles.js"; import type * as saldoFerias from "../saldoFerias.js"; @@ -67,6 +71,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + acoes: typeof acoes; "actions/email": typeof actions_email; "actions/linkPreview": typeof actions_linkPreview; "actions/pushNotifications": typeof actions_pushNotifications; @@ -80,6 +85,7 @@ declare const fullApi: ApiFromModules<{ chamadas: typeof chamadas; chamados: typeof chamados; chat: typeof chat; + config: typeof config; configuracaoEmail: typeof configuracaoEmail; configuracaoJitsi: typeof configuracaoJitsi; configuracaoPonto: typeof configuracaoPonto; @@ -102,9 +108,11 @@ declare const fullApi: ApiFromModules<{ logsAtividades: typeof logsAtividades; logsLogin: typeof logsLogin; monitoramento: typeof monitoramento; + pedidos: typeof pedidos; permissoesAcoes: typeof permissoesAcoes; pontos: typeof pontos; preferenciasNotificacao: typeof preferenciasNotificacao; + produtos: typeof produtos; pushNotifications: typeof pushNotifications; roles: typeof roles; saldoFerias: typeof saldoFerias; diff --git a/packages/backend/convex/acoes.ts b/packages/backend/convex/acoes.ts new file mode 100644 index 0000000..0097ef1 --- /dev/null +++ b/packages/backend/convex/acoes.ts @@ -0,0 +1,56 @@ +import { mutation, query } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('acoes').collect(); + } +}); + +export const create = mutation({ + args: { + nome: v.string(), + tipo: v.union(v.literal('projeto'), v.literal('lei')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + return await ctx.db.insert('acoes', { + ...args, + criadoPor: user._id, + criadoEm: Date.now() + }); + } +}); + +export const update = mutation({ + args: { + id: v.id('acoes'), + nome: v.string(), + tipo: v.union(v.literal('projeto'), v.literal('lei')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.patch(args.id, { + nome: args.nome, + tipo: args.tipo + }); + } +}); + +export const remove = mutation({ + args: { + id: v.id('acoes') + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.delete(args.id); + } +}); diff --git a/packages/backend/convex/config.ts b/packages/backend/convex/config.ts new file mode 100644 index 0000000..d12a2a6 --- /dev/null +++ b/packages/backend/convex/config.ts @@ -0,0 +1,38 @@ +import { mutation, query } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; + +export const getComprasSetor = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('config').first(); + } +}); + +export const updateComprasSetor = mutation({ + args: { + setorId: v.id('setores') + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + // Check if user has permission (e.g., admin or TI) - For now, assuming any auth user can set it, + // but in production should be restricted. + + const existingConfig = await ctx.db.query('config').first(); + + if (existingConfig) { + await ctx.db.patch(existingConfig._id, { + comprasSetorId: args.setorId, + atualizadoEm: Date.now() + }); + } else { + await ctx.db.insert('config', { + comprasSetorId: args.setorId, + criadoPor: user._id, + atualizadoEm: Date.now() + }); + } + } +}); diff --git a/packages/backend/convex/pedidos.ts b/packages/backend/convex/pedidos.ts new file mode 100644 index 0000000..96691c2 --- /dev/null +++ b/packages/backend/convex/pedidos.ts @@ -0,0 +1,596 @@ +import { mutation, query, internalMutation } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; +import { api, internal } from './_generated/api'; +import type { Doc, Id } from './_generated/dataModel'; +import type { QueryCtx, MutationCtx } from './_generated/server'; + +// ========== HELPERS ========== + +async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + return user; +} + +// ========== QUERIES ========== + +export const list = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id('pedidos'), + _creationTime: v.number(), + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + ), + handler: async (ctx) => { + return await ctx.db.query('pedidos').collect(); + } +}); + +export const get = query({ + args: { id: v.id('pedidos') }, + returns: v.union( + v.object({ + _id: v.id('pedidos'), + _creationTime: v.number(), + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }), + v.null() + ), + handler: async (ctx, args) => { + return await ctx.db.get(args.id); + } +}); + +export const getItems = query({ + args: { pedidoId: v.id('pedidos') }, + returns: v.array( + v.object({ + _id: v.id('pedidoItems'), + _creationTime: v.number(), + pedidoId: v.id('pedidos'), + produtoId: v.id('produtos'), + valorEstimado: v.string(), + valorReal: v.optional(v.string()), + quantidade: v.number(), + adicionadoPor: v.id('funcionarios'), + adicionadoPorNome: v.string(), + criadoEm: v.number() + }) + ), + handler: async (ctx, args) => { + const items = await ctx.db + .query('pedidoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .collect(); + + // Get employee names + const itemsWithNames = await Promise.all( + items.map(async (item) => { + const funcionario = await ctx.db.get(item.adicionadoPor); + return { + ...item, + adicionadoPorNome: funcionario?.nome || 'Desconhecido' + }; + }) + ); + + return itemsWithNames; + } +}); + +export const getHistory = query({ + args: { pedidoId: v.id('pedidos') }, + handler: async (ctx, args) => { + const history = await ctx.db + .query('historicoPedidos') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .order('desc') + .collect(); + + // Get user names + const historyWithNames = await Promise.all( + history.map(async (entry) => { + const usuario = await ctx.db.get(entry.usuarioId); + return { + _id: entry._id, + _creationTime: entry._creationTime, + pedidoId: entry.pedidoId, + usuarioId: entry.usuarioId, + usuarioNome: usuario?.nome || 'Desconhecido', + acao: entry.acao, + detalhes: entry.detalhes, + data: entry.data + }; + }) + ); + + return historyWithNames; + } +}); + +export const checkExisting = query({ + args: { + acaoId: v.optional(v.id('acoes')), + numeroSei: v.optional(v.string()), + produtoIds: v.optional(v.array(v.id('produtos'))) + }, + returns: v.array( + v.object({ + _id: v.id('pedidos'), + _creationTime: v.number(), + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number(), + matchingItems: v.optional( + v.array( + v.object({ + produtoId: v.id('produtos'), + quantidade: v.number() + }) + ) + ) + }) + ), + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) return []; + + const openStatuses: Array< + 'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes' + > = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes']; + + // 1) Buscar todos os pedidos "abertos" usando o índice by_status + let pedidosAbertos: Doc<'pedidos'>[] = []; + for (const status of openStatuses) { + const partial = await ctx.db + .query('pedidos') + .withIndex('by_status', (q) => q.eq('status', status)) + .collect(); + pedidosAbertos = pedidosAbertos.concat(partial); + } + + // 2) Filtros opcionais: acaoId e numeroSei + pedidosAbertos = pedidosAbertos.filter((p) => { + if (args.acaoId && p.acaoId !== args.acaoId) return false; + if (args.numeroSei && p.numeroSei !== args.numeroSei) return false; + return true; + }); + + // 3) Filtro por produtos (se informado) e coleta de matchingItems + const resultados = []; + + for (const pedido of pedidosAbertos) { + let include = true; + let matchingItems: { produtoId: Id<'produtos'>; quantidade: number }[] = []; + + // Se houver filtro de produtos, verificamos se o pedido tem ALGUM dos produtos + if (args.produtoIds && args.produtoIds.length > 0) { + const items = await ctx.db + .query('pedidoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id)) + .collect(); + + // const pedidoProdutoIds = new Set(items.map((i) => i.produtoId)); // Unused + const matching = items.filter((i) => args.produtoIds?.includes(i.produtoId)); + + if (matching.length > 0) { + matchingItems = matching.map((i) => ({ + produtoId: i.produtoId, + quantidade: i.quantidade + })); + } else { + // Se foi pedido filtro por produtos e não tem nenhum match, ignoramos este pedido + // A MENOS que tenha dado match por numeroSei ou acaoId? + // A regra original era: "Filtro por produtos (se informado)" + // Se o usuário informou produtos, ele quer ver pedidos que tenham esses produtos. + // Mas se ele TAMBÉM informou numeroSei, talvez ele queira ver aquele pedido específico mesmo sem o produto? + // Vamos manter a lógica de "E": se informou produtos, tem que ter o produto. + include = false; + } + } + + if (include) { + resultados.push({ + _id: pedido._id, + _creationTime: pedido._creationTime, + numeroSei: pedido.numeroSei, + status: pedido.status, + acaoId: pedido.acaoId, + criadoPor: pedido.criadoPor, + criadoEm: pedido.criadoEm, + atualizadoEm: pedido.atualizadoEm, + matchingItems: matchingItems.length > 0 ? matchingItems : undefined + }); + } + } + + return resultados; + } +}); + +// ========== MUTATIONS ========== + +export const create = mutation({ + args: { + numeroSei: v.optional(v.string()), + acaoId: v.optional(v.id('acoes')) + }, + returns: v.id('pedidos'), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + // 1. Check Config + const config = await ctx.db.query('config').first(); + if (!config || !config.comprasSetorId) { + throw new Error('Setor de Compras não configurado. Contate o administrador.'); + } + + // 2. Check Existing (Double check) + if (args.acaoId) { + const existing = await ctx.db + .query('pedidos') + .withIndex('by_acaoId', (q) => q.eq('acaoId', args.acaoId)) + .filter((q) => + q.or( + q.eq(q.field('status'), 'em_rascunho'), + q.eq(q.field('status'), 'aguardando_aceite'), + q.eq(q.field('status'), 'em_analise'), + q.eq(q.field('status'), 'precisa_ajustes') + ) + ) + .first(); + + if (existing) { + throw new Error('Já existe um pedido em andamento para esta ação.'); + } + } + + // 3. Create Order + const pedidoId = await ctx.db.insert('pedidos', { + numeroSei: args.numeroSei, + status: 'em_rascunho', + acaoId: args.acaoId, + criadoPor: user._id, + criadoEm: Date.now(), + atualizadoEm: Date.now() + }); + + // 4. Create History + await ctx.db.insert('historicoPedidos', { + pedidoId, + usuarioId: user._id, + acao: 'criacao', + detalhes: JSON.stringify({ numeroSei: args.numeroSei, acaoId: args.acaoId }), + data: Date.now() + }); + + return pedidoId; + } +}); + +export const updateSeiNumber = mutation({ + args: { + pedidoId: v.id('pedidos'), + numeroSei: v.string() + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) throw new Error('Pedido not found'); + + // Check if SEI number is already taken by another order + const existing = await ctx.db + .query('pedidos') + .filter((q) => + q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId)) + ) + .first(); + + if (existing) { + throw new Error('Este número SEI já está em uso por outro pedido.'); + } + + const oldSei = pedido.numeroSei; + + await ctx.db.patch(args.pedidoId, { + numeroSei: args.numeroSei, + atualizadoEm: Date.now() + }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: args.pedidoId, + usuarioId: user._id, + acao: 'atualizacao_sei', + detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }), + data: Date.now() + }); + } +}); + +export const addItem = mutation({ + args: { + pedidoId: v.id('pedidos'), + produtoId: v.id('produtos'), + valorEstimado: v.string(), + quantidade: v.number() + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + // Ensure user has a funcionarioId linked + if (!user.funcionarioId) { + throw new Error('Usuário não vinculado a um funcionário.'); + } + + await ctx.db.insert('pedidoItems', { + pedidoId: args.pedidoId, + produtoId: args.produtoId, + valorEstimado: args.valorEstimado, + quantidade: args.quantidade, + adicionadoPor: user.funcionarioId, + criadoEm: Date.now() + }); + + await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: args.pedidoId, + usuarioId: user._id, + acao: 'adicao_item', + detalhes: JSON.stringify({ + produtoId: args.produtoId, + valor: args.valorEstimado, + quantidade: args.quantidade + }), + data: Date.now() + }); + } +}); + +export const updateItemQuantity = mutation({ + args: { + itemId: v.id('pedidoItems'), + novaQuantidade: v.number() + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + if (!user.funcionarioId) { + throw new Error('Usuário não vinculado a um funcionário.'); + } + + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error('Item não encontrado.'); + + const quantidadeAnterior = item.quantidade; + + // Check permission: only item owner can decrease quantity + const isOwner = item.adicionadoPor === user.funcionarioId; + const isDecreasing = args.novaQuantidade < quantidadeAnterior; + + if (isDecreasing && !isOwner) { + throw new Error( + 'Apenas quem adicionou este item pode diminuir a quantidade. Você pode apenas aumentar.' + ); + } + + // Update quantity + await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade }); + await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); + + // Create history entry + await ctx.db.insert('historicoPedidos', { + pedidoId: item.pedidoId, + usuarioId: user._id, + acao: 'alteracao_quantidade', + detalhes: JSON.stringify({ + produtoId: item.produtoId, + quantidadeAnterior, + novaQuantidade: args.novaQuantidade + }), + data: Date.now() + }); + } +}); + +export const removeItem = mutation({ + args: { + itemId: v.id('pedidoItems') + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + + const item = await ctx.db.get(args.itemId); + if (!item) throw new Error('Item not found'); + + await ctx.db.delete(args.itemId); + await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: item.pedidoId, + usuarioId: user._id, + acao: 'remocao_item', + detalhes: JSON.stringify({ produtoId: item.produtoId, valor: item.valorEstimado }), + data: Date.now() + }); + } +}); + +export const updateStatus = mutation({ + args: { + pedidoId: v.id('pedidos'), + novoStatus: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ) + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getUsuarioAutenticado(ctx); + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) throw new Error('Pedido not found'); + + const oldStatus = pedido.status; + + await ctx.db.patch(args.pedidoId, { + status: args.novoStatus, + atualizadoEm: Date.now() + }); + + await ctx.db.insert('historicoPedidos', { + pedidoId: args.pedidoId, + usuarioId: user._id, + acao: 'alteracao_status', + detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }), + data: Date.now() + }); + + // Trigger Notifications + await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { + pedidoId: args.pedidoId, + oldStatus, + newStatus: args.novoStatus, + actorId: user._id + }); + } +}); + +// ========== INTERNAL (NOTIFICATIONS) ========== + +export const notifyStatusChange = internalMutation({ + args: { + pedidoId: v.id('pedidos'), + oldStatus: v.string(), + newStatus: v.string(), + actorId: v.id('usuarios') + }, + returns: v.null(), + handler: async (ctx, args) => { + const pedido = await ctx.db.get(args.pedidoId); + if (!pedido) return; + + const actor = await ctx.db.get(args.actorId); + const actorName = actor ? actor.nome : 'Alguém'; + + const recipients = new Set(); // Set of User IDs + + // 1. If status is "aguardando_aceite", notify Purchasing Sector + if (args.newStatus === 'aguardando_aceite') { + const config = await ctx.db.query('config').first(); + if (config && config.comprasSetorId) { + // Find all employees in this sector + const funcionarioSetores = await ctx.db + .query('funcionarioSetores') + .withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!)) + .collect(); + + const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId); + + // Find users linked to these employees + for (const fId of funcionarioIds) { + const user = await ctx.db + .query('usuarios') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId)) + .first(); + if (user) recipients.add(user._id); + } + } + } + + // 2. Notify "Involved" users (Creator + Item Adders) + // Always notify creator (unless they are the actor) + if (pedido.criadoPor !== args.actorId) { + recipients.add(pedido.criadoPor); + } + + // Notify item adders + const items = await ctx.db + .query('pedidoItems') + .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) + .collect(); + + for (const item of items) { + const user = await ctx.db + .query('usuarios') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor)) + .first(); + if (user && user._id !== args.actorId) { + recipients.add(user._id); + } + } + + // Send Notifications + for (const recipientId of recipients) { + const recipientIdTyped = recipientId as Id<'usuarios'>; + + // 1. In-App Notification + await ctx.db.insert('notificacoes', { + usuarioId: recipientIdTyped, + tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications + titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`, + descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, + lida: false, + criadaEm: Date.now(), + remetenteId: args.actorId + }); + + // 2. Email Notification (Async) + const recipientUser = await ctx.db.get(recipientIdTyped); + if (recipientUser && recipientUser.email) { + // Using enfileirarEmail directly + await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, { + destinatario: recipientUser.email, + destinatarioId: recipientIdTyped, + assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`, + corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`, + enviadoPor: args.actorId + }); + } + } + } +}); diff --git a/packages/backend/convex/permissoesAcoes.ts b/packages/backend/convex/permissoesAcoes.ts index 02cac86..aa93593 100644 --- a/packages/backend/convex/permissoesAcoes.ts +++ b/packages/backend/convex/permissoesAcoes.ts @@ -395,6 +395,100 @@ const PERMISSOES_BASE = { recurso: 'fluxos_documentos', acao: 'excluir', descricao: 'Excluir documentos de fluxos' + }, + // Pedidos + { + nome: 'pedidos.listar', + recurso: 'pedidos', + acao: 'listar', + descricao: 'Listar pedidos' + }, + { + nome: 'pedidos.criar', + recurso: 'pedidos', + acao: 'criar', + descricao: 'Criar novos pedidos' + }, + { + nome: 'pedidos.ver', + recurso: 'pedidos', + acao: 'ver', + descricao: 'Visualizar detalhes de pedidos' + }, + { + nome: 'pedidos.editar_status', + recurso: 'pedidos', + acao: 'editar_status', + descricao: 'Alterar status de pedidos' + }, + { + nome: 'pedidos.adicionar_item', + recurso: 'pedidos', + acao: 'adicionar_item', + descricao: 'Adicionar itens ao pedido' + }, + { + nome: 'pedidos.remover_item', + recurso: 'pedidos', + acao: 'remover_item', + descricao: 'Remover itens do pedido' + }, + // Produtos + { + nome: 'produtos.listar', + recurso: 'produtos', + acao: 'listar', + descricao: 'Listar produtos' + }, + { + nome: 'produtos.criar', + recurso: 'produtos', + acao: 'criar', + descricao: 'Criar novos produtos' + }, + { + nome: 'produtos.editar', + recurso: 'produtos', + acao: 'editar', + descricao: 'Editar produtos' + }, + { + nome: 'produtos.excluir', + recurso: 'produtos', + acao: 'excluir', + descricao: 'Excluir produtos' + }, + // Ações + { + nome: 'acoes.listar', + recurso: 'acoes', + acao: 'listar', + descricao: 'Listar ações' + }, + { + nome: 'acoes.criar', + recurso: 'acoes', + acao: 'criar', + descricao: 'Criar novas ações' + }, + { + nome: 'acoes.editar', + recurso: 'acoes', + acao: 'editar', + descricao: 'Editar ações' + }, + { + nome: 'acoes.excluir', + recurso: 'acoes', + acao: 'excluir', + descricao: 'Excluir ações' + }, + // Configuração Compras + { + nome: 'config.compras.gerenciar', + recurso: 'config', + acao: 'gerenciar_compras', + descricao: 'Gerenciar configurações de compras' } ] } as const; diff --git a/packages/backend/convex/produtos.ts b/packages/backend/convex/produtos.ts new file mode 100644 index 0000000..3161e36 --- /dev/null +++ b/packages/backend/convex/produtos.ts @@ -0,0 +1,69 @@ +import { mutation, query } from './_generated/server'; +import { v } from 'convex/values'; +import { getCurrentUserFunction } from './auth'; + +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('produtos').collect(); + } +}); + +export const search = query({ + args: { query: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query('produtos') + .withSearchIndex('search_nome', (q) => q.search('nome', args.query)) + .take(10); + } +}); + +export const create = mutation({ + args: { + nome: v.string(), + valorEstimado: v.string(), + tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + return await ctx.db.insert('produtos', { + ...args, + criadoPor: user._id, + criadoEm: Date.now() + }); + } +}); + +export const update = mutation({ + args: { + id: v.id('produtos'), + nome: v.string(), + valorEstimado: v.string(), + tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')) + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.patch(args.id, { + nome: args.nome, + valorEstimado: args.valorEstimado, + tipo: args.tipo + }); + } +}); + +export const remove = mutation({ + args: { + id: v.id('produtos') + }, + handler: async (ctx, args) => { + const user = await getCurrentUserFunction(ctx); + if (!user) throw new Error('Unauthorized'); + + await ctx.db.delete(args.id); + } +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 905319f..01f8edb 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1,1856 +1,1885 @@ -import { defineSchema, defineTable } from "convex/server"; -import { Infer, v } from "convex/values"; +import { defineSchema, defineTable } from 'convex/server'; +import { Infer, v } from 'convex/values'; export const simboloTipo = v.union( - v.literal("cargo_comissionado"), - v.literal("funcao_gratificada") + v.literal('cargo_comissionado'), + v.literal('funcao_gratificada') ); export type SimboloTipo = Infer; export const ataqueCiberneticoTipo = v.union( - v.literal("phishing"), - v.literal("malware"), - v.literal("ransomware"), - v.literal("brute_force"), - v.literal("credential_stuffing"), - v.literal("sql_injection"), - v.literal("xss"), - v.literal("path_traversal"), - v.literal("command_injection"), - v.literal("nosql_injection"), - v.literal("xxe"), - v.literal("man_in_the_middle"), - v.literal("ddos"), - v.literal("engenharia_social"), - v.literal("cve_exploit"), - v.literal("apt"), - v.literal("zero_day"), - v.literal("supply_chain"), - v.literal("fileless_malware"), - v.literal("polymorphic_malware"), - v.literal("ransomware_lateral"), - v.literal("deepfake_phishing"), - v.literal("adversarial_ai"), - v.literal("side_channel"), - v.literal("firmware_bootloader"), - v.literal("bec"), - v.literal("botnet"), - v.literal("ot_ics"), - v.literal("quantum_attack") + v.literal('phishing'), + v.literal('malware'), + v.literal('ransomware'), + v.literal('brute_force'), + v.literal('credential_stuffing'), + v.literal('sql_injection'), + v.literal('xss'), + v.literal('path_traversal'), + v.literal('command_injection'), + v.literal('nosql_injection'), + v.literal('xxe'), + v.literal('man_in_the_middle'), + v.literal('ddos'), + v.literal('engenharia_social'), + v.literal('cve_exploit'), + v.literal('apt'), + v.literal('zero_day'), + v.literal('supply_chain'), + v.literal('fileless_malware'), + v.literal('polymorphic_malware'), + v.literal('ransomware_lateral'), + v.literal('deepfake_phishing'), + v.literal('adversarial_ai'), + v.literal('side_channel'), + v.literal('firmware_bootloader'), + v.literal('bec'), + v.literal('botnet'), + v.literal('ot_ics'), + v.literal('quantum_attack') ); export type AtaqueCiberneticoTipo = Infer; export const severidadeSeguranca = v.union( - v.literal("informativo"), - v.literal("baixo"), - v.literal("moderado"), - v.literal("alto"), - v.literal("critico") + v.literal('informativo'), + v.literal('baixo'), + v.literal('moderado'), + v.literal('alto'), + v.literal('critico') ); export type SeveridadeSeguranca = Infer; export const statusEventoSeguranca = v.union( - v.literal("detectado"), - v.literal("investigando"), - v.literal("contido"), - v.literal("falso_positivo"), - v.literal("escalado"), - v.literal("resolvido") + v.literal('detectado'), + v.literal('investigando'), + v.literal('contido'), + v.literal('falso_positivo'), + v.literal('escalado'), + v.literal('resolvido') ); export type StatusEventoSeguranca = Infer; export const sensorSegurancaTipo = v.union( - v.literal("network"), - v.literal("endpoint"), - v.literal("application"), - v.literal("gateway"), - v.literal("ot"), - v.literal("honeypot") + v.literal('network'), + v.literal('endpoint'), + v.literal('application'), + v.literal('gateway'), + v.literal('ot'), + v.literal('honeypot') ); export type SensorSegurancaTipo = Infer; export const sensorSegurancaStatus = v.union( - v.literal("ativo"), - v.literal("inativo"), - v.literal("degradado"), - v.literal("manutencao") + v.literal('ativo'), + v.literal('inativo'), + v.literal('degradado'), + v.literal('manutencao') ); export type SensorSegurancaStatus = Infer; export const threatIntelTipo = v.union( - v.literal("open_source"), - v.literal("commercial"), - v.literal("internal"), - v.literal("gov"), - v.literal("research") + v.literal('open_source'), + v.literal('commercial'), + v.literal('internal'), + v.literal('gov'), + v.literal('research') ); export const threatIntelFormato = v.union( - v.literal("json"), - v.literal("stix"), - v.literal("csv"), - v.literal("text"), - v.literal("custom") + v.literal('json'), + v.literal('stix'), + v.literal('csv'), + v.literal('text'), + v.literal('custom') ); export const acaoIncidenteTipo = v.union( - v.literal("block_ip"), - v.literal("unblock_ip"), - v.literal("block_port"), - v.literal("liberar_porta"), - v.literal("notificar"), - v.literal("isolar_host"), - v.literal("gerar_relatorio"), - v.literal("criar_ticket"), - v.literal("ajuste_regra"), - v.literal("custom") + v.literal('block_ip'), + v.literal('unblock_ip'), + v.literal('block_port'), + v.literal('liberar_porta'), + v.literal('notificar'), + v.literal('isolar_host'), + v.literal('gerar_relatorio'), + v.literal('criar_ticket'), + v.literal('ajuste_regra'), + v.literal('custom') ); export const acaoIncidenteStatus = v.union( - v.literal("pendente"), - v.literal("executando"), - v.literal("concluido"), - v.literal("falhou") + v.literal('pendente'), + v.literal('executando'), + v.literal('concluido'), + v.literal('falhou') ); export const reportStatus = v.union( - v.literal("pendente"), - v.literal("processando"), - v.literal("concluido"), - v.literal("falhou") + v.literal('pendente'), + v.literal('processando'), + v.literal('concluido'), + v.literal('falhou') ); // Status de templates de fluxo export const flowTemplateStatus = v.union( - v.literal("draft"), - v.literal("published"), - v.literal("archived") + v.literal('draft'), + v.literal('published'), + v.literal('archived') ); export type FlowTemplateStatus = Infer; // Status de instâncias de fluxo export const flowInstanceStatus = v.union( - v.literal("active"), - v.literal("completed"), - v.literal("cancelled") + v.literal('active'), + v.literal('completed'), + v.literal('cancelled') ); export type FlowInstanceStatus = Infer; // Status de passos de instância de fluxo export const flowInstanceStepStatus = v.union( - v.literal("pending"), - v.literal("in_progress"), - v.literal("completed"), - v.literal("blocked") + v.literal('pending'), + v.literal('in_progress'), + v.literal('completed'), + v.literal('blocked') ); export type FlowInstanceStepStatus = Infer; export const situacaoContrato = v.union( - v.literal("em_execucao"), - v.literal("rescendido"), - v.literal("aguardando_assinatura"), - v.literal("finalizado") + v.literal('em_execucao'), + v.literal('rescendido'), + v.literal('aguardando_assinatura'), + v.literal('finalizado') ); export default defineSchema({ - // Setores da organização - setores: defineTable({ - nome: v.string(), - sigla: v.string(), - criadoPor: v.id("usuarios"), - createdAt: v.number(), - }) - .index("by_nome", ["nome"]) - .index("by_sigla", ["sigla"]), - - // Relação muitos-para-muitos entre funcionários e setores - funcionarioSetores: defineTable({ - funcionarioId: v.id("funcionarios"), - setorId: v.id("setores"), - createdAt: v.number(), - }) - .index("by_funcionarioId", ["funcionarioId"]) - .index("by_setorId", ["setorId"]) - .index("by_funcionarioId_and_setorId", ["funcionarioId", "setorId"]), - - // Templates de fluxo - flowTemplates: defineTable({ - name: v.string(), - description: v.optional(v.string()), - status: flowTemplateStatus, - createdBy: v.id("usuarios"), - createdAt: v.number(), - }) - .index("by_status", ["status"]) - .index("by_createdBy", ["createdBy"]), - - // Passos de template de fluxo - flowSteps: defineTable({ - flowTemplateId: v.id("flowTemplates"), - name: v.string(), - description: v.optional(v.string()), - position: v.number(), - expectedDuration: v.number(), // em dias - setorId: v.id("setores"), - defaultAssigneeId: v.optional(v.id("usuarios")), - requiredDocuments: v.optional(v.array(v.string())), - }) - .index("by_flowTemplateId", ["flowTemplateId"]) - .index("by_flowTemplateId_and_position", ["flowTemplateId", "position"]), - - // Instâncias de fluxo - flowInstances: defineTable({ - flowTemplateId: v.id("flowTemplates"), - contratoId: v.optional(v.id("contratos")), - managerId: v.id("usuarios"), - status: flowInstanceStatus, - startedAt: v.number(), - finishedAt: v.optional(v.number()), - currentStepId: v.optional(v.id("flowInstanceSteps")), - }) - .index("by_flowTemplateId", ["flowTemplateId"]) - .index("by_contratoId", ["contratoId"]) - .index("by_managerId", ["managerId"]) - .index("by_status", ["status"]), - - // Passos de instância de fluxo - flowInstanceSteps: defineTable({ - flowInstanceId: v.id("flowInstances"), - flowStepId: v.id("flowSteps"), - setorId: v.id("setores"), - assignedToId: v.optional(v.id("usuarios")), - status: flowInstanceStepStatus, - startedAt: v.optional(v.number()), - finishedAt: v.optional(v.number()), - notes: v.optional(v.string()), - notesUpdatedBy: v.optional(v.id("usuarios")), - notesUpdatedAt: v.optional(v.number()), - dueDate: v.optional(v.number()), - }) - .index("by_flowInstanceId", ["flowInstanceId"]) - .index("by_flowInstanceId_and_status", ["flowInstanceId", "status"]) - .index("by_setorId", ["setorId"]) - .index("by_assignedToId", ["assignedToId"]), - - // Documentos de instância de fluxo - flowInstanceDocuments: defineTable({ - flowInstanceStepId: v.id("flowInstanceSteps"), - uploadedById: v.id("usuarios"), - storageId: v.id("_storage"), - name: v.string(), - uploadedAt: v.number(), - }) - .index("by_flowInstanceStepId", ["flowInstanceStepId"]) - .index("by_uploadedById", ["uploadedById"]), - - // Sub-etapas de fluxo (para templates e instâncias) - flowSubSteps: defineTable({ - flowStepId: v.optional(v.id("flowSteps")), // Para templates - flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), // Para instâncias - name: v.string(), - description: v.optional(v.string()), - status: v.union( - v.literal("pending"), - v.literal("in_progress"), - v.literal("completed"), - v.literal("blocked") - ), - position: v.number(), - createdBy: v.id("usuarios"), - createdAt: v.number(), - }) - .index("by_flowStepId", ["flowStepId"]) - .index("by_flowInstanceStepId", ["flowInstanceStepId"]), - - // Notas de steps e sub-etapas - flowStepNotes: defineTable({ - flowStepId: v.optional(v.id("flowSteps")), - flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), - flowSubStepId: v.optional(v.id("flowSubSteps")), - texto: v.string(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - arquivos: v.array(v.id("_storage")), - }) - .index("by_flowStepId", ["flowStepId"]) - .index("by_flowInstanceStepId", ["flowInstanceStepId"]) - .index("by_flowSubStepId", ["flowSubStepId"]), - - contratos: defineTable({ - contratadaId: v.id("empresas"), - objeto: v.string(), - numeroNotaEmpenho: v.string(), - responsavelId: v.id("funcionarios"), - departamento: v.string(), - situacao: situacaoContrato, - numeroProcessoLicitatorio: v.string(), - modalidade: v.string(), - numeroContrato: v.string(), - anoContrato: v.number(), - dataInicioVigencia: v.string(), - dataFimVigencia: v.string(), - nomeFiscal: v.string(), - valorTotal: v.string(), - dataAditivoPrazo: v.optional(v.string()), - diasAvisoVencimento: v.number(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - atualizadoEm: v.optional(v.number()), - }) - .index("by_responsavel", ["responsavelId"]) - .index("by_situacao", ["situacao"]) - .index("by_vigencia_inicio", ["dataInicioVigencia"]) - .index("by_vigencia_fim", ["dataFimVigencia"]), - - todos: defineTable({ - text: v.string(), - completed: v.boolean(), - }), - enderecos: defineTable({ - cep: v.string(), - logradouro: v.string(), - numero: v.string(), - complemento: v.optional(v.string()), - bairro: v.string(), - cidade: v.string(), - uf: v.string(), - criadoPor: v.optional(v.id("usuarios")), - atualizadoPor: v.optional(v.id("usuarios")), - }).index("by_cep", ["cep"]), - empresas: defineTable({ - razao_social: v.string(), - nome_fantasia: v.optional(v.string()), - cnpj: v.string(), - telefone: v.string(), - email: v.string(), - descricao: v.optional(v.string()), - enderecoId: v.optional(v.id("enderecos")), - criadoPor: v.optional(v.id("usuarios")), - }) - .index("by_razao_social", ["razao_social"]) - .index("by_cnpj", ["cnpj"]), - contatosEmpresa: defineTable({ - empresaId: v.id("empresas"), - nome: v.string(), - funcao: v.string(), - email: v.string(), - telefone: v.string(), - adicionadoPor: v.optional(v.id("usuarios")), - descricao: v.optional(v.string()), - }) - .index("by_empresa", ["empresaId"]) - .index("by_email", ["email"]), - funcionarios: defineTable({ - // Campos obrigatórios existentes - nome: v.string(), - nascimento: v.string(), - rg: v.string(), - cpf: v.string(), - endereco: v.string(), - cep: v.string(), - cidade: v.string(), - uf: v.string(), - telefone: v.string(), - email: v.string(), - matricula: v.optional(v.string()), - admissaoData: v.optional(v.string()), - desligamentoData: v.optional(v.string()), - simboloId: v.id("simbolos"), - simboloTipo: simboloTipo, - gestorId: v.optional(v.id("usuarios")), - statusFerias: v.optional( - v.union(v.literal("ativo"), v.literal("em_ferias")) - ), - - // Regime de trabalho (para cálculo correto de férias) - regimeTrabalho: v.optional( - v.union( - v.literal("clt"), // CLT - Consolidação das Leis do Trabalho - v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco - v.literal("estatutario_federal"), // Servidor Público Federal - v.literal("estatutario_municipal") // Servidor Público Municipal - ) - ), - - // Dados Pessoais Adicionais (opcionais) - nomePai: v.optional(v.string()), - nomeMae: v.optional(v.string()), - naturalidade: v.optional(v.string()), - naturalidadeUF: v.optional(v.string()), - sexo: v.optional( - v.union(v.literal("masculino"), v.literal("feminino"), v.literal("outro")) - ), - estadoCivil: v.optional( - v.union( - v.literal("solteiro"), - v.literal("casado"), - v.literal("divorciado"), - v.literal("viuvo"), - v.literal("uniao_estavel") - ) - ), - nacionalidade: v.optional(v.string()), - - // Documentos Pessoais - rgOrgaoExpedidor: v.optional(v.string()), - rgDataEmissao: v.optional(v.string()), - carteiraProfissionalNumero: v.optional(v.string()), - carteiraProfissionalSerie: v.optional(v.string()), - carteiraProfissionalDataEmissao: v.optional(v.string()), - reservistaNumero: v.optional(v.string()), - reservistaSerie: v.optional(v.string()), - tituloEleitorNumero: v.optional(v.string()), - tituloEleitorZona: v.optional(v.string()), - tituloEleitorSecao: v.optional(v.string()), - pisNumero: v.optional(v.string()), - - // Formação e Saúde - grauInstrucao: v.optional( - v.union( - v.literal("fundamental"), - v.literal("medio"), - v.literal("superior"), - v.literal("pos_graduacao"), - v.literal("mestrado"), - v.literal("doutorado") - ) - ), - formacao: v.optional(v.string()), - formacaoRegistro: v.optional(v.string()), - grupoSanguineo: v.optional( - v.union(v.literal("A"), v.literal("B"), v.literal("AB"), v.literal("O")) - ), - fatorRH: v.optional(v.union(v.literal("positivo"), v.literal("negativo"))), - - // Cargo e Vínculo - descricaoCargo: v.optional(v.string()), - nomeacaoPortaria: v.optional(v.string()), - nomeacaoData: v.optional(v.string()), - nomeacaoDOE: v.optional(v.string()), - pertenceOrgaoPublico: v.optional(v.boolean()), - orgaoOrigem: v.optional(v.string()), - aposentado: v.optional( - v.union(v.literal("nao"), v.literal("funape_ipsep"), v.literal("inss")) - ), - - // Dados Bancários - contaBradescoNumero: v.optional(v.string()), - contaBradescoDV: v.optional(v.string()), - contaBradescoAgencia: v.optional(v.string()), - - // Documentos Anexos (Storage IDs) - certidaoAntecedentesPF: v.optional(v.id("_storage")), - certidaoAntecedentesJFPE: v.optional(v.id("_storage")), - certidaoAntecedentesSDS: v.optional(v.id("_storage")), - certidaoAntecedentesTJPE: v.optional(v.id("_storage")), - certidaoImprobidade: v.optional(v.id("_storage")), - rgFrente: v.optional(v.id("_storage")), - rgVerso: v.optional(v.id("_storage")), - cpfFrente: v.optional(v.id("_storage")), - cpfVerso: v.optional(v.id("_storage")), - situacaoCadastralCPF: v.optional(v.id("_storage")), - tituloEleitorFrente: v.optional(v.id("_storage")), - tituloEleitorVerso: v.optional(v.id("_storage")), - comprovanteVotacao: v.optional(v.id("_storage")), - carteiraProfissionalFrente: v.optional(v.id("_storage")), - carteiraProfissionalVerso: v.optional(v.id("_storage")), - comprovantePIS: v.optional(v.id("_storage")), - certidaoRegistroCivil: v.optional(v.id("_storage")), - certidaoNascimentoDependentes: v.optional(v.id("_storage")), - cpfDependentes: v.optional(v.id("_storage")), - reservistaDoc: v.optional(v.id("_storage")), - comprovanteEscolaridade: v.optional(v.id("_storage")), - comprovanteResidencia: v.optional(v.id("_storage")), - comprovanteContaBradesco: v.optional(v.id("_storage")), - - // Dependentes do funcionário (uploads opcionais) - dependentes: v.optional( - v.array( - v.object({ - parentesco: v.union( - v.literal("filho"), - v.literal("filha"), - v.literal("conjuge"), - v.literal("outro") - ), - nome: v.string(), - cpf: v.string(), - nascimento: v.string(), - documentoId: v.optional(v.id("_storage")), - // Benefícios/declarações por dependente - salarioFamilia: v.optional(v.boolean()), - impostoRenda: v.optional(v.boolean()), - }) - ) - ), - - // Declarações (Storage IDs) - declaracaoAcumulacaoCargo: v.optional(v.id("_storage")), - declaracaoDependentesIR: v.optional(v.id("_storage")), - declaracaoIdoneidade: v.optional(v.id("_storage")), - termoNepotismo: v.optional(v.id("_storage")), - termoOpcaoRemuneracao: v.optional(v.id("_storage")), - }) - .index("by_matricula", ["matricula"]) - .index("by_nome", ["nome"]) - .index("by_simboloId", ["simboloId"]) - .index("by_simboloTipo", ["simboloTipo"]) - .index("by_cpf", ["cpf"]) - .index("by_rg", ["rg"]) - .index("by_gestor", ["gestorId"]), - - atestados: defineTable({ - funcionarioId: v.id("funcionarios"), - tipo: v.union( - v.literal("atestado_medico"), - v.literal("declaracao_comparecimento") - ), - dataInicio: v.string(), - dataFim: v.string(), - cid: v.optional(v.string()), // Apenas para atestado médico - observacoes: v.optional(v.string()), - documentoId: v.optional(v.id("_storage")), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_tipo", ["tipo"]) - .index("by_data_inicio", ["dataInicio"]) - .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]), - - licencas: defineTable({ - funcionarioId: v.id("funcionarios"), - tipo: v.union(v.literal("maternidade"), v.literal("paternidade")), - dataInicio: v.string(), - dataFim: v.string(), - documentoId: v.optional(v.id("_storage")), - observacoes: v.optional(v.string()), - licencaOriginalId: v.optional(v.id("licencas")), // Para prorrogações - ehProrrogacao: v.boolean(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_tipo", ["tipo"]) - .index("by_data_inicio", ["dataInicio"]) - .index("by_licenca_original", ["licencaOriginalId"]) - .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]), - - ferias: defineTable({ - funcionarioId: v.id("funcionarios"), - anoReferencia: v.number(), - dataInicio: v.string(), - dataFim: v.string(), - diasFerias: v.number(), - status: v.union( - v.literal("aguardando_aprovacao"), - v.literal("aprovado"), - v.literal("reprovado"), - v.literal("data_ajustada_aprovada"), - v.literal("EmFérias"), - v.literal("Cancelado_RH") - ), - gestorId: v.optional(v.id("usuarios")), - observacao: v.optional(v.string()), - motivoReprovacao: v.optional(v.string()), - dataAprovacao: v.optional(v.number()), - dataReprovacao: v.optional(v.number()), - diasAbono: v.number(), - historicoAlteracoes: v.optional( - v.array( - v.object({ - data: v.number(), - usuarioId: v.id("usuarios"), - acao: v.string(), - }) - ) - ), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_funcionario_and_ano", ["funcionarioId", "anoReferencia"]) - .index("by_funcionario_and_status", ["funcionarioId", "status"]) - .index("by_status", ["status"]) - .index("by_ano", ["anoReferencia"]), - - notificacoesFerias: defineTable({ - destinatarioId: v.id("usuarios"), - feriasId: v.id("ferias"), - tipo: v.union( - v.literal("nova_solicitacao"), - v.literal("aprovado"), - v.literal("reprovado"), - v.literal("data_ajustada") - ), - lida: v.boolean(), - mensagem: v.string(), - }) - .index("by_destinatario", ["destinatarioId"]) - .index("by_destinatario_and_lida", ["destinatarioId", "lida"]), - - // Solicitações de Ausências - solicitacoesAusencias: defineTable({ - funcionarioId: v.id("funcionarios"), - dataInicio: v.string(), - dataFim: v.string(), - motivo: v.string(), - status: v.union( - v.literal("aguardando_aprovacao"), - v.literal("aprovado"), - v.literal("reprovado") - ), - gestorId: v.optional(v.id("usuarios")), - dataAprovacao: v.optional(v.number()), - dataReprovacao: v.optional(v.number()), - motivoReprovacao: v.optional(v.string()), - observacao: v.optional(v.string()), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_status", ["status"]) - .index("by_funcionario_and_status", ["funcionarioId", "status"]), - - notificacoesAusencias: defineTable({ - destinatarioId: v.id("usuarios"), - solicitacaoAusenciaId: v.id("solicitacoesAusencias"), - tipo: v.union( - v.literal("nova_solicitacao"), - v.literal("aprovado"), - v.literal("reprovado") - ), - lida: v.boolean(), - mensagem: v.string(), - }) - .index("by_destinatario", ["destinatarioId"]) - .index("by_destinatario_and_lida", ["destinatarioId", "lida"]), - - - times: defineTable({ - nome: v.string(), - descricao: v.optional(v.string()), - gestorId: v.id("usuarios"), - gestorSuperiorId: v.optional(v.id("usuarios")), - ativo: v.boolean(), - cor: v.optional(v.string()), // Cor para identificação visual - }) - .index("by_gestor", ["gestorId"]) - .index("by_gestor_superior", ["gestorSuperiorId"]), - - timesMembros: defineTable({ - timeId: v.id("times"), - funcionarioId: v.id("funcionarios"), - dataEntrada: v.number(), - dataSaida: v.optional(v.number()), - ativo: v.boolean(), - }) - .index("by_time", ["timeId"]) - .index("by_funcionario", ["funcionarioId"]) - .index("by_time_and_ativo", ["timeId", "ativo"]), - - cursos: defineTable({ - funcionarioId: v.id("funcionarios"), - descricao: v.string(), - data: v.string(), - certificadoId: v.optional(v.id("_storage")), - }).index("by_funcionario", ["funcionarioId"]), - - simbolos: defineTable({ - nome: v.string(), - tipo: simboloTipo, - descricao: v.string(), - vencValor: v.string(), - repValor: v.string(), - valor: v.string(), - }), - - // Sistema de Autenticação e Controle de Acesso - usuarios: defineTable({ - authId: v.string(), - nome: v.string(), - email: v.string(), - funcionarioId: v.optional(v.id("funcionarios")), - roleId: v.id("roles"), - ativo: v.boolean(), - primeiroAcesso: v.boolean(), - ultimoAcesso: v.optional(v.number()), - criadoEm: v.number(), - atualizadoEm: v.number(), - - // Controle de Bloqueio e Segurança - bloqueado: v.optional(v.boolean()), - motivoBloqueio: v.optional(v.string()), - dataBloqueio: v.optional(v.number()), - tentativasLogin: v.optional(v.number()), // contador de tentativas falhas - ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa - - // Campos de Chat e Perfil - - fotoPerfil: v.optional(v.id("_storage")), - avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear) - setor: v.optional(v.string()), - statusMensagem: v.optional(v.string()), // max 100 chars - statusPresenca: v.optional( - v.union( - v.literal("online"), - v.literal("offline"), - v.literal("ausente"), - v.literal("externo"), - v.literal("em_reuniao") - ) - ), - ultimaAtividade: v.optional(v.number()), // timestamp - notificacoesAtivadas: v.optional(v.boolean()), - somNotificacao: v.optional(v.boolean()), - temaPreferido: v.optional(v.string()), // tema de aparência escolhido pelo usuário - }) - .index("by_email", ["email"]) - .index("by_role", ["roleId"]) - .index("by_ativo", ["ativo"]) - .index("by_status_presenca", ["statusPresenca"]) - .index("by_bloqueado", ["bloqueado"]) - .index("by_funcionarioId", ["funcionarioId"]) - .index("authId", ["authId"]), - - roles: defineTable({ - nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario" - descricao: v.string(), - nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado - setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc. - customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER - criadoPor: v.optional(v.id("usuarios")), // usuário TI_MASTER que criou este perfil - editavel: v.optional(v.boolean()), // se pode ser editado (false para roles fixas) - }) - .index("by_nome", ["nome"]) - .index("by_nivel", ["nivel"]) - .index("by_setor", ["setor"]) - .index("by_customizado", ["customizado"]), - - permissoes: defineTable({ - nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc. - descricao: v.string(), - recurso: v.string(), // "funcionarios", "simbolos", "usuarios", etc. - acao: v.string(), // "criar", "ler", "editar", "excluir" - }) - .index("by_recurso", ["recurso"]) - .index("by_recurso_e_acao", ["recurso", "acao"]) - .index("by_nome", ["nome"]), - - rolePermissoes: defineTable({ - roleId: v.id("roles"), - permissaoId: v.id("permissoes"), - }) - .index("by_role", ["roleId"]) - .index("by_permissao", ["permissaoId"]), - - sessoes: defineTable({ - usuarioId: v.id("usuarios"), - token: v.string(), - ipAddress: v.optional(v.string()), - userAgent: v.optional(v.string()), - criadoEm: v.number(), - expiraEm: v.number(), - ativo: v.boolean(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_token", ["token"]) - .index("by_ativo", ["ativo"]) - .index("by_expiracao", ["expiraEm"]), - - logsAcesso: defineTable({ - usuarioId: v.id("usuarios"), - tipo: v.union( - v.literal("login"), - v.literal("logout"), - v.literal("acesso_negado"), - v.literal("senha_alterada"), - v.literal("sessao_expirada") - ), - ipAddress: v.optional(v.string()), - userAgent: v.optional(v.string()), - detalhes: v.optional(v.string()), - timestamp: v.number(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_tipo", ["tipo"]) - .index("by_timestamp", ["timestamp"]), - - // Logs de Login Detalhados - logsLogin: defineTable({ - usuarioId: v.optional(v.id("usuarios")), // pode ser null se falha antes de identificar usuário - matriculaOuEmail: v.string(), // tentativa de login - sucesso: v.boolean(), - motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente" - ipAddress: v.optional(v.string()), - userAgent: v.optional(v.string()), - device: v.optional(v.string()), - browser: v.optional(v.string()), - sistema: v.optional(v.string()), - timestamp: v.number(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_sucesso", ["sucesso"]) - .index("by_timestamp", ["timestamp"]) - .index("by_ip", ["ipAddress"]), - - // Logs de Atividades - logsAtividades: defineTable({ - usuarioId: v.id("usuarios"), - acao: v.string(), // "criar", "editar", "excluir", "bloquear", "desbloquear", etc. - recurso: v.string(), // "funcionarios", "simbolos", "usuarios", "perfis", etc. - recursoId: v.optional(v.string()), // ID do recurso afetado - detalhes: v.optional(v.string()), // JSON com detalhes da ação - timestamp: v.number(), - }) - .index("by_usuario", ["usuarioId"]) - .index("by_acao", ["acao"]) - .index("by_recurso", ["recurso"]) - .index("by_timestamp", ["timestamp"]) - .index("by_recurso_id", ["recurso", "recursoId"]), - - // Histórico de Bloqueios - bloqueiosUsuarios: defineTable({ - usuarioId: v.id("usuarios"), - motivo: v.string(), - bloqueadoPor: v.id("usuarios"), // ID do TI_MASTER que bloqueou - dataInicio: v.number(), - dataFim: v.optional(v.number()), // quando foi desbloqueado - desbloqueadoPor: v.optional(v.id("usuarios")), - ativo: v.boolean(), // se é o bloqueio atual ativo - }) - .index("by_usuario", ["usuarioId"]) - .index("by_bloqueado_por", ["bloqueadoPor"]) - .index("by_ativo", ["ativo"]) - .index("by_data_inicio", ["dataInicio"]), - - // Perfis Customizados - - // Templates de Mensagens - templatesMensagens: defineTable({ - codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc. - nome: v.string(), - tipo: v.union( - v.literal("sistema"), // predefinido, não editável - v.literal("customizado") // criado por TI_MASTER - ), - titulo: v.string(), - corpo: v.string(), // pode ter variáveis {{variavel}} - variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.] - criadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - }) - .index("by_codigo", ["codigo"]) - .index("by_tipo", ["tipo"]) - .index("by_criado_por", ["criadoPor"]), - - // Configuração de Email/SMTP - configuracaoEmail: defineTable({ - servidor: v.string(), // smtp.gmail.com - porta: v.number(), // 587, 465, etc. - usuario: v.string(), - senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP - emailRemetente: v.string(), - nomeRemetente: v.string(), - usarSSL: v.boolean(), - usarTLS: v.boolean(), - ativo: v.boolean(), - testadoEm: v.optional(v.number()), - configuradoPor: v.id("usuarios"), - atualizadoEm: v.number(), - }).index("by_ativo", ["ativo"]), - - // Configuração de Jitsi Meet - configuracaoJitsi: defineTable({ - domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com") - appId: v.string(), // ID da aplicação Jitsi - roomPrefix: v.string(), // Prefixo para nomes de salas - useHttps: v.boolean(), // Usar HTTPS - acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento) - // Configurações SSH/Docker para configuração automática do servidor - sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local") - sshPort: v.optional(v.number()), // Porta SSH (padrão: 22) - sshUsername: v.optional(v.string()), // Usuário SSH - sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada) - sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha) - dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker") - jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg") - ativo: v.boolean(), // Configuração ativa - testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão - configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker - configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor - 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 - }).index("by_ativo", ["ativo"]), - - // Fila de Emails - notificacoesEmail: defineTable({ - destinatario: v.string(), // email - destinatarioId: v.optional(v.id("usuarios")), - assunto: v.string(), - corpo: v.string(), // HTML ou texto - templateId: v.optional(v.id("templatesMensagens")), - status: v.union( - v.literal("pendente"), - v.literal("enviando"), - v.literal("enviado"), - v.literal("falha") - ), - tentativas: v.number(), - ultimaTentativa: v.optional(v.number()), - erroDetalhes: v.optional(v.string()), - enviadoPor: v.id("usuarios"), - criadoEm: v.number(), - enviadoEm: v.optional(v.number()), - agendadaPara: v.optional(v.number()), // timestamp para agendamento - }) - .index("by_status", ["status"]) - .index("by_destinatario", ["destinatarioId"]) - .index("by_enviado_por", ["enviadoPor"]) - .index("by_criado_em", ["criadoEm"]) - .index("by_agendamento", ["agendadaPara"]), - - configuracaoAcesso: defineTable({ - chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc. - valor: v.string(), - descricao: v.string(), - }).index("by_chave", ["chave"]), - - // Rate Limiting de Emails - rateLimitEmails: defineTable({ - remetenteId: v.id("usuarios"), - timestamp: v.number(), - contador: v.number(), // quantidade de emails enviados neste período - periodo: v.union( - v.literal("minuto"), // último minuto - v.literal("hora") // última hora - ), - }) - .index("by_remetente_periodo", ["remetenteId", "periodo", "timestamp"]) - .index("by_timestamp", ["timestamp"]), - - // Sistema de Chat - conversas: defineTable({ - tipo: v.union( - v.literal("individual"), - v.literal("grupo"), - v.literal("sala_reuniao") - ), - nome: v.optional(v.string()), // nome do grupo/sala - - participantes: v.array(v.id("usuarios")), // IDs dos participantes - administradores: v.optional(v.array(v.id("usuarios"))), // IDs dos administradores (apenas para sala_reuniao) - ultimaMensagem: v.optional(v.string()), - ultimaMensagemTimestamp: v.optional(v.number()), - ultimaMensagemRemetenteId: v.optional(v.id("usuarios")), // ID do remetente da última mensagem - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_criado_por", ["criadoPor"]) - .index("by_tipo", ["tipo"]) - .index("by_ultima_mensagem", ["ultimaMensagemTimestamp"]), - - mensagens: defineTable({ - conversaId: v.id("conversas"), - remetenteId: v.id("usuarios"), - tipo: v.union( - v.literal("texto"), - v.literal("arquivo"), - v.literal("imagem") - ), - conteudo: v.string(), // texto ou nome do arquivo - conteudoBusca: v.optional(v.string()), // versão normalizada para busca - arquivoId: v.optional(v.id("_storage")), - arquivoNome: v.optional(v.string()), - arquivoTamanho: v.optional(v.number()), - arquivoTipo: v.optional(v.string()), - linkPreview: v.optional( - v.object({ - url: v.string(), - titulo: v.optional(v.string()), - descricao: v.optional(v.string()), - imagem: v.optional(v.string()), - site: v.optional(v.string()), - }) - ), - reagiuPor: v.optional( - v.array( - v.object({ - usuarioId: v.id("usuarios"), - emoji: v.string(), - }) - ) - ), - mencoes: v.optional(v.array(v.id("usuarios"))), - respostaPara: v.optional(v.id("mensagens")), // ID da mensagem que está respondendo - agendadaPara: v.optional(v.number()), // timestamp - enviadaEm: v.number(), - editadaEm: v.optional(v.number()), - deletada: v.optional(v.boolean()), - lidaPor: v.optional(v.array(v.id("usuarios"))), // IDs dos usuários que leram a mensagem - }) - .index("by_conversa", ["conversaId", "enviadaEm"]) - .index("by_remetente", ["remetenteId"]) - .index("by_agendamento", ["agendadaPara"]) - .index("by_resposta", ["respostaPara"]), - - leituras: defineTable({ - conversaId: v.id("conversas"), - usuarioId: v.id("usuarios"), - ultimaMensagemLida: v.id("mensagens"), - lidaEm: v.number(), - }) - .index("by_conversa_usuario", ["conversaId", "usuarioId"]) - .index("by_usuario", ["usuarioId"]), - - // Sistema de Chamadas de Áudio/Vídeo - chamadas: defineTable({ - conversaId: v.id("conversas"), - tipo: v.union(v.literal("audio"), v.literal("video")), - roomName: v.string(), // Nome único da sala Jitsi - criadoPor: v.id("usuarios"), // Anfitrião/criador - participantes: v.array(v.id("usuarios")), - status: v.union( - v.literal("aguardando"), - v.literal("em_andamento"), - v.literal("finalizada"), - v.literal("cancelada") - ), - iniciadaEm: v.optional(v.number()), - finalizadaEm: v.optional(v.number()), - duracaoSegundos: v.optional(v.number()), - gravando: v.boolean(), - gravacaoIniciadaPor: v.optional(v.id("usuarios")), - gravacaoIniciadaEm: v.optional(v.number()), - gravacaoFinalizadaEm: v.optional(v.number()), - configuracoes: v.optional(v.object({ - audioHabilitado: v.boolean(), - videoHabilitado: v.boolean(), - participantesConfig: v.optional(v.array(v.object({ - usuarioId: v.id("usuarios"), - audioHabilitado: v.boolean(), - videoHabilitado: v.boolean(), - forcadoPeloAnfitriao: v.optional(v.boolean()), // Se foi forçado pelo anfitrião - }))) - })), - criadoEm: v.number(), - }) - .index("by_conversa", ["conversaId", "status"]) - .index("by_criado_por", ["criadoPor"]) - .index("by_status", ["status"]) - .index("by_room_name", ["roomName"]), - - notificacoes: defineTable({ - usuarioId: v.id("usuarios"), - tipo: v.union( - v.literal("nova_mensagem"), - v.literal("mencao"), - v.literal("grupo_criado"), - v.literal("adicionado_grupo"), - v.literal("alerta_seguranca"), - v.literal("etapa_fluxo_concluida") - ), - conversaId: v.optional(v.id("conversas")), - mensagemId: v.optional(v.id("mensagens")), - remetenteId: v.optional(v.id("usuarios")), - titulo: v.string(), - descricao: v.string(), - lida: v.boolean(), - criadaEm: v.number(), - }) - .index("by_usuario", ["usuarioId", "lida", "criadaEm"]) - .index("by_usuario_lida", ["usuarioId", "lida"]), - - digitando: defineTable({ - conversaId: v.id("conversas"), - usuarioId: v.id("usuarios"), - iniciouEm: v.number(), - }) - .index("by_conversa", ["conversaId", "iniciouEm"]) - .index("by_usuario", ["usuarioId"]), - - // Push Notifications - pushSubscriptions: defineTable({ - usuarioId: v.id("usuarios"), - endpoint: v.string(), // URL do serviço de push - keys: v.object({ - p256dh: v.string(), // Chave pública - auth: v.string(), // Chave de autenticação - }), - userAgent: v.optional(v.string()), - criadoEm: v.number(), - ultimaAtividade: v.number(), - ativo: v.boolean(), - }) - .index("by_usuario", ["usuarioId", "ativo"]) - .index("by_endpoint", ["endpoint"]), - - // Preferências de Notificação por Conversa - preferenciasNotificacaoConversa: defineTable({ - usuarioId: v.id("usuarios"), - conversaId: v.id("conversas"), - pushAtivado: v.boolean(), // Receber push notifications - emailAtivado: v.boolean(), // Receber emails quando offline - somAtivado: v.boolean(), // Tocar som - silenciado: v.boolean(), // Silenciar completamente - apenasMencoes: v.boolean(), // Notificar apenas quando mencionado - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_usuario_conversa", ["usuarioId", "conversaId"]) - .index("by_conversa", ["conversaId"]), - - // Tabelas de Monitoramento do Sistema - systemMetrics: defineTable({ - timestamp: v.number(), - // Métricas de Sistema - cpuUsage: v.optional(v.number()), - memoryUsage: v.optional(v.number()), - networkLatency: v.optional(v.number()), - storageUsed: v.optional(v.number()), - // Métricas de Aplicação - usuariosOnline: v.optional(v.number()), - mensagensPorMinuto: v.optional(v.number()), - tempoRespostaMedio: v.optional(v.number()), - errosCount: v.optional(v.number()), - }).index("by_timestamp", ["timestamp"]), - - alertConfigurations: defineTable({ - metricName: v.string(), - threshold: v.number(), - operator: v.union( - v.literal(">"), - v.literal("<"), - v.literal(">="), - v.literal("<="), - v.literal("==") - ), - enabled: v.boolean(), - notifyByEmail: v.boolean(), - notifyByChat: v.boolean(), - createdBy: v.id("usuarios"), - lastModified: v.number(), - }).index("by_enabled", ["enabled"]), - - alertHistory: defineTable({ - configId: v.id("alertConfigurations"), - metricName: v.string(), - metricValue: v.number(), - threshold: v.number(), - timestamp: v.number(), - status: v.union(v.literal("triggered"), v.literal("resolved")), - notificationsSent: v.object({ - email: v.boolean(), - chat: v.boolean(), - }), - }) - .index("by_timestamp", ["timestamp"]) - .index("by_status", ["status"]) - .index("by_config", ["configId", "timestamp"]), - - tickets: defineTable({ - numero: v.string(), - titulo: v.string(), - descricao: v.string(), - tipo: v.union( - v.literal("reclamacao"), - v.literal("elogio"), - v.literal("sugestao"), - v.literal("chamado") - ), - categoria: v.optional(v.string()), - status: v.union( - v.literal("aberto"), - v.literal("em_andamento"), - v.literal("aguardando_usuario"), - v.literal("resolvido"), - v.literal("encerrado"), - v.literal("cancelado") - ), - prioridade: v.union( - v.literal("baixa"), - v.literal("media"), - v.literal("alta"), - v.literal("critica") - ), - solicitanteId: v.id("usuarios"), - solicitanteNome: v.string(), - solicitanteEmail: v.string(), - responsavelId: v.optional(v.id("usuarios")), - setorResponsavel: v.optional(v.string()), - slaConfigId: v.optional(v.id("slaConfigs")), - conversaId: v.optional(v.id("conversas")), - prazoResposta: v.optional(v.number()), - prazoConclusao: v.optional(v.number()), - prazoEncerramento: v.optional(v.number()), - timeline: v.optional( - v.array( - v.object({ - etapa: v.string(), - status: v.union( - v.literal("pendente"), - v.literal("em_andamento"), - v.literal("concluido"), - v.literal("vencido") - ), - prazo: v.optional(v.number()), - concluidoEm: v.optional(v.number()), - observacao: v.optional(v.string()), - }) - ) - ), - alertasEmitidos: v.optional( - v.array( - v.object({ - tipo: v.union( - v.literal("resposta"), - v.literal("conclusao"), - v.literal("encerramento") - ), - emitidoEm: v.number(), - }) - ) - ), - anexos: v.optional( - v.array( - v.object({ - arquivoId: v.id("_storage"), - nome: v.optional(v.string()), - tipo: v.optional(v.string()), - tamanho: v.optional(v.number()), - }) - ) - ), - tags: v.optional(v.array(v.string())), - canalOrigem: v.optional(v.string()), - ultimaInteracaoEm: v.number(), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_numero", ["numero"]) - .index("by_status", ["status"]) - .index("by_solicitante", ["solicitanteId", "status"]) - .index("by_responsavel", ["responsavelId", "status"]) - .index("by_setor", ["setorResponsavel", "status"]), - - ticketInteractions: defineTable({ - ticketId: v.id("tickets"), - autorId: v.optional(v.id("usuarios")), - origem: v.union( - v.literal("usuario"), - v.literal("ti"), - v.literal("sistema") - ), - tipo: v.union( - v.literal("mensagem"), - v.literal("status"), - v.literal("anexo"), - v.literal("alerta") - ), - conteudo: v.string(), - anexos: v.optional( - v.array( - v.object({ - arquivoId: v.id("_storage"), - nome: v.optional(v.string()), - tipo: v.optional(v.string()), - tamanho: v.optional(v.number()), - }) - ) - ), - statusAnterior: v.optional( - v.union( - v.literal("aberto"), - v.literal("em_andamento"), - v.literal("aguardando_usuario"), - v.literal("resolvido"), - v.literal("encerrado"), - v.literal("cancelado") - ) - ), - statusNovo: v.optional( - v.union( - v.literal("aberto"), - v.literal("em_andamento"), - v.literal("aguardando_usuario"), - v.literal("resolvido"), - v.literal("encerrado"), - v.literal("cancelado") - ) - ), - visibilidade: v.union( - v.literal("publico"), - v.literal("interno") - ), - criadoEm: v.number(), - }) - .index("by_ticket", ["ticketId"]) - .index("by_ticket_type", ["ticketId", "tipo"]) - .index("by_autor", ["autorId"]), - - slaConfigs: defineTable({ - nome: v.string(), - descricao: v.optional(v.string()), - prioridade: v.optional( - v.union( - v.literal("baixa"), - v.literal("media"), - v.literal("alta"), - v.literal("critica") - ) - ), - tempoRespostaHoras: v.number(), - tempoConclusaoHoras: v.number(), - tempoEncerramentoHoras: v.optional(v.number()), - alertaAntecedenciaHoras: v.number(), - ativo: v.boolean(), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_ativo", ["ativo"]) - .index("by_prioridade", ["prioridade", "ativo"]) - .index("by_nome", ["nome"]), - - ticketAssignments: defineTable({ - ticketId: v.id("tickets"), - responsavelId: v.id("usuarios"), - atribuidoPor: v.id("usuarios"), - motivo: v.optional(v.string()), - ativo: v.boolean(), - criadoEm: v.number(), - encerradoEm: v.optional(v.number()), - }) - .index("by_ticket", ["ticketId", "ativo"]) - .index("by_responsavel", ["responsavelId", "ativo"]), - - // Sistema de Segurança Cibernética - networkSensors: defineTable({ - nome: v.string(), - tipo: sensorSegurancaTipo, - status: sensorSegurancaStatus, - escopo: v.optional(v.string()), - ipMonitorado: v.optional(v.string()), - hostname: v.optional(v.string()), - regioes: v.optional(v.array(v.string())), - portasMonitoradas: v.optional(v.array(v.number())), - protocolos: v.optional(v.array(v.string())), - capacidades: v.optional(v.array(v.string())), - ultimaSincronizacao: v.number(), - ultimoHeartbeat: v.optional(v.number()), - latenciaMs: v.optional(v.number()), - errosConsecutivos: v.optional(v.number()), - agenteVersao: v.optional(v.string()), - notas: v.optional(v.string()), - }) - .index("by_tipo", ["tipo"]) - .index("by_status", ["status"]) - .index("by_hostname", ["hostname"]), - - ipReputation: defineTable({ - indicador: v.string(), - categoria: v.union( - v.literal("ip"), - v.literal("dominio"), - v.literal("hash"), - v.literal("email") - ), - reputacao: v.number(), // -100 (malicioso) até 100 (confiável) - severidadeMax: severidadeSeguranca, - whitelist: v.boolean(), - blacklist: v.boolean(), - ocorrencias: v.number(), - primeiroRegistro: v.number(), - ultimoRegistro: v.number(), - bloqueadoAte: v.optional(v.number()), - origem: v.optional(v.string()), - comentarios: v.optional(v.string()), - classificacoes: v.optional(v.array(v.string())), - ultimaAcaoId: v.optional(v.id("incidentActions")), - }) - .index("by_indicador", ["indicador"]) - .index("by_reputacao", ["reputacao"]) - .index("by_blacklist", ["blacklist"]) - .index("by_whitelist", ["whitelist"]), - - portRules: defineTable({ - porta: v.number(), - protocolo: v.union( - v.literal("tcp"), - v.literal("udp"), - v.literal("icmp"), - v.literal("quic"), - v.literal("any") - ), - acao: v.union( - v.literal("permitir"), - v.literal("bloquear"), - v.literal("monitorar"), - v.literal("rate_limit") - ), - temporario: v.boolean(), - severidadeMin: severidadeSeguranca, - duracaoSegundos: v.optional(v.number()), - expiraEm: v.optional(v.number()), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - notas: v.optional(v.string()), - tags: v.optional(v.array(v.string())), - listaReferencia: v.optional(v.id("ipReputation")), - }) - .index("by_porta_protocolo", ["porta", "protocolo"]) - .index("by_acao", ["acao"]) - .index("by_expiracao", ["expiraEm"]), - - threatIntelFeeds: defineTable({ - nomeFonte: v.string(), - tipo: threatIntelTipo, - formato: threatIntelFormato, - url: v.optional(v.string()), - ativo: v.boolean(), - prioridade: v.union( - v.literal("baixa"), - v.literal("media"), - v.literal("alta"), - v.literal("critica") - ), - ultimaSincronizacao: v.optional(v.number()), - entradasProcessadas: v.optional(v.number()), - errosConsecutivos: v.optional(v.number()), - autenticacaoNecessaria: v.optional(v.boolean()), - configuracao: v.optional( - v.object({ - tokenId: v.optional(v.id("_storage")), - escopo: v.optional(v.string()), - }) - ), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_tipo", ["tipo"]) - .index("by_ativo", ["ativo"]) - .index("by_prioridade", ["prioridade"]), - - securityEvents: defineTable({ - referencia: v.string(), - timestamp: v.number(), - tipoAtaque: ataqueCiberneticoTipo, - severidade: severidadeSeguranca, - status: statusEventoSeguranca, - descricao: v.string(), - origemIp: v.optional(v.string()), - origemRegiao: v.optional(v.string()), - origemAsn: v.optional(v.string()), - destinoIp: v.optional(v.string()), - destinoPorta: v.optional(v.number()), - protocolo: v.optional(v.string()), - transporte: v.optional(v.string()), - sensorId: v.optional(v.id("networkSensors")), - detectadoPor: v.optional(v.string()), - mitreTechnique: v.optional(v.string()), - geolocalizacao: v.optional( - v.object({ - pais: v.optional(v.string()), - regiao: v.optional(v.string()), - cidade: v.optional(v.string()), - latitude: v.optional(v.number()), - longitude: v.optional(v.number()), - }) - ), - fingerprint: v.optional( - v.object({ - userAgent: v.optional(v.string()), - deviceId: v.optional(v.string()), - ja3: v.optional(v.string()), - tlsVersion: v.optional(v.string()), - }) - ), - indicadores: v.optional( - v.array( - v.object({ - tipo: v.string(), - valor: v.string(), - confianca: v.optional(v.number()), - }) - ) - ), - metricas: v.optional( - v.object({ - pps: v.optional(v.number()), - bps: v.optional(v.number()), - rpm: v.optional(v.number()), - errosPorSegundo: v.optional(v.number()), - hostsAfetados: v.optional(v.number()), - }) - ), - correlacoes: v.optional(v.array(v.id("securityEvents"))), - referenciasExternas: v.optional(v.array(v.string())), - tags: v.optional(v.array(v.string())), - criadoPor: v.optional(v.id("usuarios")), - atualizadoEm: v.number(), - }) - .index("by_referencia", ["referencia"]) - .index("by_timestamp", ["timestamp"]) - .index("by_tipo", ["tipoAtaque", "timestamp"]) - .index("by_severidade", ["severidade", "timestamp"]) - .index("by_status", ["status", "timestamp"]), - - incidentActions: defineTable({ - eventoId: v.id("securityEvents"), - tipo: acaoIncidenteTipo, - origem: v.union(v.literal("automatico"), v.literal("manual")), - status: acaoIncidenteStatus, - executadoPor: v.optional(v.id("usuarios")), - detalhes: v.optional(v.string()), - resultado: v.optional(v.string()), - relacionadoA: v.optional(v.id("ipReputation")), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_evento", ["eventoId", "status"]) - .index("by_tipo", ["tipo", "status"]), - - reportRequests: defineTable({ - solicitanteId: v.id("usuarios"), - filtros: v.object({ - dataInicio: v.number(), - dataFim: v.number(), - severidades: v.optional(v.array(severidadeSeguranca)), - tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), - incluirIndicadores: v.optional(v.boolean()), - incluirMetricas: v.optional(v.boolean()), - incluirAcoes: v.optional(v.boolean()), - }), - status: reportStatus, - resultadoId: v.optional(v.id("_storage")), - observacoes: v.optional(v.string()), - criadoEm: v.number(), - atualizadoEm: v.number(), - concluidoEm: v.optional(v.number()), - erro: v.optional(v.string()), - }) - .index("by_status", ["status"]) - .index("by_solicitante", ["solicitanteId", "status"]) - .index("by_criado_em", ["criadoEm"]), - - rateLimitConfig: defineTable({ - nome: v.string(), - tipo: v.union( - v.literal("ip"), - v.literal("usuario"), - v.literal("endpoint"), - v.literal("global") - ), - identificador: v.optional(v.string()), - limite: v.number(), - janelaSegundos: v.number(), - estrategia: v.union( - v.literal("fixed_window"), - v.literal("sliding_window"), - v.literal("token_bucket") - ), - acaoExcedido: v.union( - v.literal("bloquear"), - v.literal("throttle"), - v.literal("alertar") - ), - bloqueioTemporarioSegundos: v.optional(v.number()), - ativo: v.boolean(), - prioridade: v.number(), - criadoPor: v.id("usuarios"), - atualizadoPor: v.optional(v.id("usuarios")), - criadoEm: v.number(), - atualizadoEm: v.number(), - notas: v.optional(v.string()), - tags: v.optional(v.array(v.string())) - }) - .index("by_tipo_identificador", ["tipo", "identificador"]) - .index("by_ativo", ["ativo"]) - .index("by_prioridade", ["prioridade"]) - , - alertConfigs: defineTable({ - nome: v.string(), - canais: v.object({ - email: v.boolean(), - chat: v.boolean(), - }), - emails: v.array(v.string()), - chatUsers: v.array(v.string()), - severidadeMin: severidadeSeguranca, - tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), - reenvioMin: v.number(), - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - atualizadoEm: v.number(), - }) - .index("by_criadoEm", ["criadoEm"]), - - // Sistema de Controle de Ponto - registrosPonto: defineTable({ - funcionarioId: v.id("funcionarios"), - tipo: v.union( - v.literal("entrada"), - v.literal("saida_almoco"), - v.literal("retorno_almoco"), - v.literal("saida") - ), - data: v.string(), // YYYY-MM-DD - hora: v.number(), - minuto: v.number(), - segundo: v.number(), - timestamp: v.number(), // Timestamp completo para ordenação - imagemId: v.optional(v.id("_storage")), - sincronizadoComServidor: v.boolean(), - toleranciaMinutos: v.number(), - dentroDoPrazo: v.boolean(), - - // Informações de Rede - ipAddress: v.optional(v.string()), - ipPublico: v.optional(v.string()), - ipLocal: v.optional(v.string()), - - // Informações do Navegador - userAgent: v.optional(v.string()), - browser: v.optional(v.string()), - browserVersion: v.optional(v.string()), - engine: v.optional(v.string()), - - // Informações do Sistema - sistemaOperacional: v.optional(v.string()), - osVersion: v.optional(v.string()), - arquitetura: v.optional(v.string()), - plataforma: v.optional(v.string()), - - // Informações de Localização - latitude: v.optional(v.number()), - longitude: v.optional(v.number()), - precisao: v.optional(v.number()), - altitude: v.optional(v.union(v.number(), v.null())), - altitudeAccuracy: v.optional(v.union(v.number(), v.null())), - heading: v.optional(v.union(v.number(), v.null())), - speed: v.optional(v.union(v.number(), v.null())), - confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend) - scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend) - suspeitaSpoofing: v.optional(v.boolean()), - motivoSuspeita: v.optional(v.string()), - avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação - distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS - velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro - distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro - tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro - // Informações de Geofencing - enderecoMarcacaoEsperado: v.optional(v.id("enderecosMarcacao")), // Endereço mais próximo esperado - distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado - dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido - enderecoMarcacaoUsado: v.optional(v.id("enderecosMarcacao")), // Qual endereço foi usado na validação - raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros - endereco: v.optional(v.string()), - cidade: v.optional(v.string()), - estado: v.optional(v.string()), - pais: v.optional(v.string()), - timezone: v.optional(v.string()), - - // Informações do Dispositivo - deviceType: v.optional(v.string()), - deviceModel: v.optional(v.string()), - screenResolution: v.optional(v.string()), - coresTela: v.optional(v.number()), - idioma: v.optional(v.string()), - - // Informações Adicionais - isMobile: v.optional(v.boolean()), - isTablet: v.optional(v.boolean()), - isDesktop: v.optional(v.boolean()), - connectionType: v.optional(v.string()), - memoryInfo: v.optional(v.string()), - - // Informações de Sensores (Acelerômetro e Giroscópio) - acelerometroX: v.optional(v.number()), - acelerometroY: v.optional(v.number()), - acelerometroZ: v.optional(v.number()), - movimentoDetectado: v.optional(v.boolean()), - magnitudeMovimento: v.optional(v.number()), - variacaoAcelerometro: v.optional(v.number()), - giroscopioAlpha: v.optional(v.number()), - giroscopioBeta: v.optional(v.number()), - giroscopioGamma: v.optional(v.number()), - sensorDisponivel: v.optional(v.boolean()), - permissaoSensorNegada: v.optional(v.boolean()), - - // Justificativa opcional para o registro - justificativa: v.optional(v.string()), - - // Campos para homologação - editadoPorGestor: v.optional(v.boolean()), - homologacaoId: v.optional(v.id("homologacoesPonto")), - - criadoEm: v.number(), - }) - .index("by_funcionario_data", ["funcionarioId", "data"]) - .index("by_data", ["data"]) - .index("by_dentro_prazo", ["dentroDoPrazo", "data"]) - .index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]), - - // Endereços de Marcação - Locais permitidos para registro de ponto - enderecosMarcacao: defineTable({ - nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC" - descricao: v.optional(v.string()), // Descrição opcional - // Coordenadas (obrigatórias) - latitude: v.number(), - longitude: v.number(), - // Endereço físico (para exibição) - endereco: v.string(), // Ex: "Rua Exemplo, 123" - bairro: v.optional(v.string()), // Bairro do endereço - cep: v.optional(v.string()), - cidade: v.string(), - estado: v.string(), - pais: v.optional(v.string()), // Padrão: "Brasil" - // Configurações - raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m) - ativo: v.boolean(), - // Tipos de uso - tipo: v.union( - v.literal("sede"), // Sede principal (para todos) - v.literal("home_office"), // Home office específico - v.literal("deslocamento"), // Deslocamento temporário - v.literal("cliente") // Local de cliente - ), - // Metadados - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - atualizadoPor: v.optional(v.id("usuarios")), - atualizadoEm: v.optional(v.number()), - }) - .index("by_ativo", ["ativo"]) - .index("by_tipo", ["tipo"]) - .index("by_cidade", ["cidade"]), - - // Associação Funcionário ↔ Endereço de Marcação - funcionarioEnderecosMarcacao: defineTable({ - funcionarioId: v.id("funcionarios"), - enderecoMarcacaoId: v.id("enderecosMarcacao"), - // Configurações específicas do funcionário - raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão - // Período de validade (para deslocamentos temporários) - dataInicio: v.optional(v.string()), // YYYY-MM-DD - dataFim: v.optional(v.string()), // YYYY-MM-DD - // Status - ativo: v.boolean(), - // Metadados - criadoPor: v.id("usuarios"), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_endereco", ["enderecoMarcacaoId"]) - .index("by_funcionario_ativo", ["funcionarioId", "ativo"]) - .index("by_endereco_ativo", ["enderecoMarcacaoId", "ativo"]), - - configuracaoPonto: defineTable({ - horarioEntrada: v.string(), // HH:mm - horarioSaidaAlmoco: v.string(), // HH:mm - horarioRetornoAlmoco: v.string(), // HH:mm - horarioSaida: v.string(), // HH:mm - toleranciaMinutos: v.number(), - // Nomes personalizados dos tipos de registro - nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1" - nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1" - nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2" - nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" - // Ajuste de fuso horário (GMT offset em horas) - gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) - // Configurações de geofencing - validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização - toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros - ativo: v.boolean(), - atualizadoPor: v.id("usuarios"), - atualizadoEm: v.number(), - }) - .index("by_ativo", ["ativo"]), - - configuracaoRelogio: defineTable({ - servidorNTP: v.optional(v.string()), - portaNTP: v.optional(v.number()), - usarServidorExterno: v.boolean(), - fallbackParaPC: v.boolean(), - ultimaSincronizacao: v.optional(v.number()), - offsetSegundos: v.optional(v.number()), - // Ajuste de fuso horário (GMT offset em horas) - gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) - atualizadoPor: v.id("usuarios"), - atualizadoEm: v.number(), - }) - .index("by_ativo", ["usarServidorExterno"]), - - // Banco de Horas - Saldo diário de horas trabalhadas - bancoHoras: defineTable({ - funcionarioId: v.id("funcionarios"), - data: v.string(), // YYYY-MM-DD - cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) - horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) - saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) - registrosPontoIds: v.array(v.id("registrosPonto")), // IDs dos registros do dia - calculadoEm: v.number(), - }) - .index("by_funcionario_data", ["funcionarioId", "data"]) - .index("by_funcionario", ["funcionarioId"]) - .index("by_data", ["data"]), - - // Homologações de Ponto - Edições e ajustes realizados pelo gestor - homologacoesPonto: defineTable({ - registroId: v.optional(v.id("registrosPonto")), // ID do registro editado (se for edição) - funcionarioId: v.id("funcionarios"), - gestorId: v.id("usuarios"), - // Dados do registro original (se for edição) - horaAnterior: v.optional(v.number()), - minutoAnterior: v.optional(v.number()), - // Dados do registro novo (se for edição) - horaNova: v.optional(v.number()), - minutoNova: v.optional(v.number()), - // Motivo e observações - motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações) - motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc) - motivoDescricao: v.optional(v.string()), // Descrição do motivo - observacoes: v.optional(v.string()), - // Tipo de ajuste (se for ajuste de banco de horas) - tipoAjuste: v.optional(v.union( - v.literal("compensar"), - v.literal("abonar"), - v.literal("descontar") - )), - // Período do ajuste (se for ajuste de banco de horas) - periodoDias: v.optional(v.number()), - periodoHoras: v.optional(v.number()), - periodoMinutos: v.optional(v.number()), - // Ajuste em minutos (calculado) - ajusteMinutos: v.optional(v.number()), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_gestor", ["gestorId"]) - .index("by_registro", ["registroId"]) - .index("by_data", ["criadoEm"]), - - // Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto - dispensasRegistro: defineTable({ - funcionarioId: v.id("funcionarios"), - gestorId: v.id("usuarios"), - dataInicio: v.string(), // YYYY-MM-DD - horaInicio: v.number(), - minutoInicio: v.number(), - dataFim: v.string(), // YYYY-MM-DD - horaFim: v.number(), - minutoFim: v.number(), - motivo: v.string(), - isento: v.boolean(), // Se true, não expira (casos excepcionais) - ativo: v.boolean(), - criadoEm: v.number(), - }) - .index("by_funcionario", ["funcionarioId"]) - .index("by_gestor", ["gestorId"]) - .index("by_ativo", ["ativo"]) - .index("by_data_inicio", ["dataInicio"]) - .index("by_data_fim", ["dataFim"]), + // Setores da organização + setores: defineTable({ + nome: v.string(), + sigla: v.string(), + criadoPor: v.id('usuarios'), + createdAt: v.number() + }) + .index('by_nome', ['nome']) + .index('by_sigla', ['sigla']), + + // Relação muitos-para-muitos entre funcionários e setores + funcionarioSetores: defineTable({ + funcionarioId: v.id('funcionarios'), + setorId: v.id('setores'), + createdAt: v.number() + }) + .index('by_funcionarioId', ['funcionarioId']) + .index('by_setorId', ['setorId']) + .index('by_funcionarioId_and_setorId', ['funcionarioId', 'setorId']), + + // Templates de fluxo + flowTemplates: defineTable({ + name: v.string(), + description: v.optional(v.string()), + status: flowTemplateStatus, + createdBy: v.id('usuarios'), + createdAt: v.number() + }) + .index('by_status', ['status']) + .index('by_createdBy', ['createdBy']), + + // Passos de template de fluxo + flowSteps: defineTable({ + flowTemplateId: v.id('flowTemplates'), + name: v.string(), + description: v.optional(v.string()), + position: v.number(), + expectedDuration: v.number(), // em dias + setorId: v.id('setores'), + defaultAssigneeId: v.optional(v.id('usuarios')), + requiredDocuments: v.optional(v.array(v.string())) + }) + .index('by_flowTemplateId', ['flowTemplateId']) + .index('by_flowTemplateId_and_position', ['flowTemplateId', 'position']), + + // Instâncias de fluxo + flowInstances: defineTable({ + flowTemplateId: v.id('flowTemplates'), + contratoId: v.optional(v.id('contratos')), + managerId: v.id('usuarios'), + status: flowInstanceStatus, + startedAt: v.number(), + finishedAt: v.optional(v.number()), + currentStepId: v.optional(v.id('flowInstanceSteps')) + }) + .index('by_flowTemplateId', ['flowTemplateId']) + .index('by_contratoId', ['contratoId']) + .index('by_managerId', ['managerId']) + .index('by_status', ['status']), + + // Passos de instância de fluxo + flowInstanceSteps: defineTable({ + flowInstanceId: v.id('flowInstances'), + flowStepId: v.id('flowSteps'), + setorId: v.id('setores'), + assignedToId: v.optional(v.id('usuarios')), + status: flowInstanceStepStatus, + startedAt: v.optional(v.number()), + finishedAt: v.optional(v.number()), + notes: v.optional(v.string()), + notesUpdatedBy: v.optional(v.id('usuarios')), + notesUpdatedAt: v.optional(v.number()), + dueDate: v.optional(v.number()) + }) + .index('by_flowInstanceId', ['flowInstanceId']) + .index('by_flowInstanceId_and_status', ['flowInstanceId', 'status']) + .index('by_setorId', ['setorId']) + .index('by_assignedToId', ['assignedToId']), + + // Documentos de instância de fluxo + flowInstanceDocuments: defineTable({ + flowInstanceStepId: v.id('flowInstanceSteps'), + uploadedById: v.id('usuarios'), + storageId: v.id('_storage'), + name: v.string(), + uploadedAt: v.number() + }) + .index('by_flowInstanceStepId', ['flowInstanceStepId']) + .index('by_uploadedById', ['uploadedById']), + + // Sub-etapas de fluxo (para templates e instâncias) + flowSubSteps: defineTable({ + flowStepId: v.optional(v.id('flowSteps')), // Para templates + flowInstanceStepId: v.optional(v.id('flowInstanceSteps')), // Para instâncias + name: v.string(), + description: v.optional(v.string()), + status: v.union( + v.literal('pending'), + v.literal('in_progress'), + v.literal('completed'), + v.literal('blocked') + ), + position: v.number(), + createdBy: v.id('usuarios'), + createdAt: v.number() + }) + .index('by_flowStepId', ['flowStepId']) + .index('by_flowInstanceStepId', ['flowInstanceStepId']), + + // Notas de steps e sub-etapas + flowStepNotes: defineTable({ + flowStepId: v.optional(v.id('flowSteps')), + flowInstanceStepId: v.optional(v.id('flowInstanceSteps')), + flowSubStepId: v.optional(v.id('flowSubSteps')), + texto: v.string(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + arquivos: v.array(v.id('_storage')) + }) + .index('by_flowStepId', ['flowStepId']) + .index('by_flowInstanceStepId', ['flowInstanceStepId']) + .index('by_flowSubStepId', ['flowSubStepId']), + + contratos: defineTable({ + contratadaId: v.id('empresas'), + objeto: v.string(), + numeroNotaEmpenho: v.string(), + responsavelId: v.id('funcionarios'), + departamento: v.string(), + situacao: situacaoContrato, + numeroProcessoLicitatorio: v.string(), + modalidade: v.string(), + numeroContrato: v.string(), + anoContrato: v.number(), + dataInicioVigencia: v.string(), + dataFimVigencia: v.string(), + nomeFiscal: v.string(), + valorTotal: v.string(), + dataAditivoPrazo: v.optional(v.string()), + diasAvisoVencimento: v.number(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.optional(v.number()) + }) + .index('by_responsavel', ['responsavelId']) + .index('by_situacao', ['situacao']) + .index('by_vigencia_inicio', ['dataInicioVigencia']) + .index('by_vigencia_fim', ['dataFimVigencia']), + + todos: defineTable({ + text: v.string(), + completed: v.boolean() + }), + enderecos: defineTable({ + cep: v.string(), + logradouro: v.string(), + numero: v.string(), + complemento: v.optional(v.string()), + bairro: v.string(), + cidade: v.string(), + uf: v.string(), + criadoPor: v.optional(v.id('usuarios')), + atualizadoPor: v.optional(v.id('usuarios')) + }).index('by_cep', ['cep']), + empresas: defineTable({ + razao_social: v.string(), + nome_fantasia: v.optional(v.string()), + cnpj: v.string(), + telefone: v.string(), + email: v.string(), + descricao: v.optional(v.string()), + enderecoId: v.optional(v.id('enderecos')), + criadoPor: v.optional(v.id('usuarios')) + }) + .index('by_razao_social', ['razao_social']) + .index('by_cnpj', ['cnpj']), + contatosEmpresa: defineTable({ + empresaId: v.id('empresas'), + nome: v.string(), + funcao: v.string(), + email: v.string(), + telefone: v.string(), + adicionadoPor: v.optional(v.id('usuarios')), + descricao: v.optional(v.string()) + }) + .index('by_empresa', ['empresaId']) + .index('by_email', ['email']), + funcionarios: defineTable({ + // Campos obrigatórios existentes + nome: v.string(), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), + matricula: v.optional(v.string()), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), + simboloId: v.id('simbolos'), + simboloTipo: simboloTipo, + gestorId: v.optional(v.id('usuarios')), + statusFerias: v.optional(v.union(v.literal('ativo'), v.literal('em_ferias'))), + + // Regime de trabalho (para cálculo correto de férias) + regimeTrabalho: v.optional( + v.union( + v.literal('clt'), // CLT - Consolidação das Leis do Trabalho + v.literal('estatutario_pe'), // Servidor Público Estadual de Pernambuco + v.literal('estatutario_federal'), // Servidor Público Federal + v.literal('estatutario_municipal') // Servidor Público Municipal + ) + ), + + // Dados Pessoais Adicionais (opcionais) + nomePai: v.optional(v.string()), + nomeMae: v.optional(v.string()), + naturalidade: v.optional(v.string()), + naturalidadeUF: v.optional(v.string()), + sexo: v.optional(v.union(v.literal('masculino'), v.literal('feminino'), v.literal('outro'))), + estadoCivil: v.optional( + v.union( + v.literal('solteiro'), + v.literal('casado'), + v.literal('divorciado'), + v.literal('viuvo'), + v.literal('uniao_estavel') + ) + ), + nacionalidade: v.optional(v.string()), + + // Documentos Pessoais + rgOrgaoExpedidor: v.optional(v.string()), + rgDataEmissao: v.optional(v.string()), + carteiraProfissionalNumero: v.optional(v.string()), + carteiraProfissionalSerie: v.optional(v.string()), + carteiraProfissionalDataEmissao: v.optional(v.string()), + reservistaNumero: v.optional(v.string()), + reservistaSerie: v.optional(v.string()), + tituloEleitorNumero: v.optional(v.string()), + tituloEleitorZona: v.optional(v.string()), + tituloEleitorSecao: v.optional(v.string()), + pisNumero: v.optional(v.string()), + + // Formação e Saúde + grauInstrucao: v.optional( + v.union( + v.literal('fundamental'), + v.literal('medio'), + v.literal('superior'), + v.literal('pos_graduacao'), + v.literal('mestrado'), + v.literal('doutorado') + ) + ), + formacao: v.optional(v.string()), + formacaoRegistro: v.optional(v.string()), + grupoSanguineo: v.optional( + v.union(v.literal('A'), v.literal('B'), v.literal('AB'), v.literal('O')) + ), + fatorRH: v.optional(v.union(v.literal('positivo'), v.literal('negativo'))), + + // Cargo e Vínculo + descricaoCargo: v.optional(v.string()), + nomeacaoPortaria: v.optional(v.string()), + nomeacaoData: v.optional(v.string()), + nomeacaoDOE: v.optional(v.string()), + pertenceOrgaoPublico: v.optional(v.boolean()), + orgaoOrigem: v.optional(v.string()), + aposentado: v.optional(v.union(v.literal('nao'), v.literal('funape_ipsep'), v.literal('inss'))), + + // Dados Bancários + contaBradescoNumero: v.optional(v.string()), + contaBradescoDV: v.optional(v.string()), + contaBradescoAgencia: v.optional(v.string()), + + // Documentos Anexos (Storage IDs) + certidaoAntecedentesPF: v.optional(v.id('_storage')), + certidaoAntecedentesJFPE: v.optional(v.id('_storage')), + certidaoAntecedentesSDS: v.optional(v.id('_storage')), + certidaoAntecedentesTJPE: v.optional(v.id('_storage')), + certidaoImprobidade: v.optional(v.id('_storage')), + rgFrente: v.optional(v.id('_storage')), + rgVerso: v.optional(v.id('_storage')), + cpfFrente: v.optional(v.id('_storage')), + cpfVerso: v.optional(v.id('_storage')), + situacaoCadastralCPF: v.optional(v.id('_storage')), + tituloEleitorFrente: v.optional(v.id('_storage')), + tituloEleitorVerso: v.optional(v.id('_storage')), + comprovanteVotacao: v.optional(v.id('_storage')), + carteiraProfissionalFrente: v.optional(v.id('_storage')), + carteiraProfissionalVerso: v.optional(v.id('_storage')), + comprovantePIS: v.optional(v.id('_storage')), + certidaoRegistroCivil: v.optional(v.id('_storage')), + certidaoNascimentoDependentes: v.optional(v.id('_storage')), + cpfDependentes: v.optional(v.id('_storage')), + reservistaDoc: v.optional(v.id('_storage')), + comprovanteEscolaridade: v.optional(v.id('_storage')), + comprovanteResidencia: v.optional(v.id('_storage')), + comprovanteContaBradesco: v.optional(v.id('_storage')), + + // Dependentes do funcionário (uploads opcionais) + dependentes: v.optional( + v.array( + v.object({ + parentesco: v.union( + v.literal('filho'), + v.literal('filha'), + v.literal('conjuge'), + v.literal('outro') + ), + nome: v.string(), + cpf: v.string(), + nascimento: v.string(), + documentoId: v.optional(v.id('_storage')), + // Benefícios/declarações por dependente + salarioFamilia: v.optional(v.boolean()), + impostoRenda: v.optional(v.boolean()) + }) + ) + ), + + // Declarações (Storage IDs) + declaracaoAcumulacaoCargo: v.optional(v.id('_storage')), + declaracaoDependentesIR: v.optional(v.id('_storage')), + declaracaoIdoneidade: v.optional(v.id('_storage')), + termoNepotismo: v.optional(v.id('_storage')), + termoOpcaoRemuneracao: v.optional(v.id('_storage')) + }) + .index('by_matricula', ['matricula']) + .index('by_nome', ['nome']) + .index('by_simboloId', ['simboloId']) + .index('by_simboloTipo', ['simboloTipo']) + .index('by_cpf', ['cpf']) + .index('by_rg', ['rg']) + .index('by_gestor', ['gestorId']), + + atestados: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union(v.literal('atestado_medico'), v.literal('declaracao_comparecimento')), + dataInicio: v.string(), + dataFim: v.string(), + cid: v.optional(v.string()), // Apenas para atestado médico + observacoes: v.optional(v.string()), + documentoId: v.optional(v.id('_storage')), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_tipo', ['tipo']) + .index('by_data_inicio', ['dataInicio']) + .index('by_funcionario_and_tipo', ['funcionarioId', 'tipo']), + + licencas: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union(v.literal('maternidade'), v.literal('paternidade')), + dataInicio: v.string(), + dataFim: v.string(), + documentoId: v.optional(v.id('_storage')), + observacoes: v.optional(v.string()), + licencaOriginalId: v.optional(v.id('licencas')), // Para prorrogações + ehProrrogacao: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_tipo', ['tipo']) + .index('by_data_inicio', ['dataInicio']) + .index('by_licenca_original', ['licencaOriginalId']) + .index('by_funcionario_and_tipo', ['funcionarioId', 'tipo']), + + ferias: defineTable({ + funcionarioId: v.id('funcionarios'), + anoReferencia: v.number(), + dataInicio: v.string(), + dataFim: v.string(), + diasFerias: v.number(), + status: v.union( + v.literal('aguardando_aprovacao'), + v.literal('aprovado'), + v.literal('reprovado'), + v.literal('data_ajustada_aprovada'), + v.literal('EmFérias'), + v.literal('Cancelado_RH') + ), + gestorId: v.optional(v.id('usuarios')), + observacao: v.optional(v.string()), + motivoReprovacao: v.optional(v.string()), + dataAprovacao: v.optional(v.number()), + dataReprovacao: v.optional(v.number()), + diasAbono: v.number(), + historicoAlteracoes: v.optional( + v.array( + v.object({ + data: v.number(), + usuarioId: v.id('usuarios'), + acao: v.string() + }) + ) + ) + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_funcionario_and_ano', ['funcionarioId', 'anoReferencia']) + .index('by_funcionario_and_status', ['funcionarioId', 'status']) + .index('by_status', ['status']) + .index('by_ano', ['anoReferencia']), + + notificacoesFerias: defineTable({ + destinatarioId: v.id('usuarios'), + feriasId: v.id('ferias'), + tipo: v.union( + v.literal('nova_solicitacao'), + v.literal('aprovado'), + v.literal('reprovado'), + v.literal('data_ajustada') + ), + lida: v.boolean(), + mensagem: v.string() + }) + .index('by_destinatario', ['destinatarioId']) + .index('by_destinatario_and_lida', ['destinatarioId', 'lida']), + + // Solicitações de Ausências + solicitacoesAusencias: defineTable({ + funcionarioId: v.id('funcionarios'), + dataInicio: v.string(), + dataFim: v.string(), + motivo: v.string(), + status: v.union( + v.literal('aguardando_aprovacao'), + v.literal('aprovado'), + v.literal('reprovado') + ), + gestorId: v.optional(v.id('usuarios')), + dataAprovacao: v.optional(v.number()), + dataReprovacao: v.optional(v.number()), + motivoReprovacao: v.optional(v.string()), + observacao: v.optional(v.string()), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_status', ['status']) + .index('by_funcionario_and_status', ['funcionarioId', 'status']), + + notificacoesAusencias: defineTable({ + destinatarioId: v.id('usuarios'), + solicitacaoAusenciaId: v.id('solicitacoesAusencias'), + tipo: v.union(v.literal('nova_solicitacao'), v.literal('aprovado'), v.literal('reprovado')), + lida: v.boolean(), + mensagem: v.string() + }) + .index('by_destinatario', ['destinatarioId']) + .index('by_destinatario_and_lida', ['destinatarioId', 'lida']), + + times: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + gestorId: v.id('usuarios'), + gestorSuperiorId: v.optional(v.id('usuarios')), + ativo: v.boolean(), + cor: v.optional(v.string()) // Cor para identificação visual + }) + .index('by_gestor', ['gestorId']) + .index('by_gestor_superior', ['gestorSuperiorId']), + + timesMembros: defineTable({ + timeId: v.id('times'), + funcionarioId: v.id('funcionarios'), + dataEntrada: v.number(), + dataSaida: v.optional(v.number()), + ativo: v.boolean() + }) + .index('by_time', ['timeId']) + .index('by_funcionario', ['funcionarioId']) + .index('by_time_and_ativo', ['timeId', 'ativo']), + + cursos: defineTable({ + funcionarioId: v.id('funcionarios'), + descricao: v.string(), + data: v.string(), + certificadoId: v.optional(v.id('_storage')) + }).index('by_funcionario', ['funcionarioId']), + + simbolos: defineTable({ + nome: v.string(), + tipo: simboloTipo, + descricao: v.string(), + vencValor: v.string(), + repValor: v.string(), + valor: v.string() + }), + + // Sistema de Autenticação e Controle de Acesso + usuarios: defineTable({ + authId: v.string(), + nome: v.string(), + email: v.string(), + funcionarioId: v.optional(v.id('funcionarios')), + roleId: v.id('roles'), + ativo: v.boolean(), + primeiroAcesso: v.boolean(), + ultimoAcesso: v.optional(v.number()), + criadoEm: v.number(), + atualizadoEm: v.number(), + + // Controle de Bloqueio e Segurança + bloqueado: v.optional(v.boolean()), + motivoBloqueio: v.optional(v.string()), + dataBloqueio: v.optional(v.number()), + tentativasLogin: v.optional(v.number()), // contador de tentativas falhas + ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa + + // Campos de Chat e Perfil + + fotoPerfil: v.optional(v.id('_storage')), + avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear) + setor: v.optional(v.string()), + statusMensagem: v.optional(v.string()), // max 100 chars + statusPresenca: v.optional( + v.union( + v.literal('online'), + v.literal('offline'), + v.literal('ausente'), + v.literal('externo'), + v.literal('em_reuniao') + ) + ), + ultimaAtividade: v.optional(v.number()), // timestamp + notificacoesAtivadas: v.optional(v.boolean()), + somNotificacao: v.optional(v.boolean()), + temaPreferido: v.optional(v.string()) // tema de aparência escolhido pelo usuário + }) + .index('by_email', ['email']) + .index('by_role', ['roleId']) + .index('by_ativo', ['ativo']) + .index('by_status_presenca', ['statusPresenca']) + .index('by_bloqueado', ['bloqueado']) + .index('by_funcionarioId', ['funcionarioId']) + .index('authId', ['authId']), + + roles: defineTable({ + nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario" + descricao: v.string(), + nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado + setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc. + customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER + criadoPor: v.optional(v.id('usuarios')), // usuário TI_MASTER que criou este perfil + editavel: v.optional(v.boolean()) // se pode ser editado (false para roles fixas) + }) + .index('by_nome', ['nome']) + .index('by_nivel', ['nivel']) + .index('by_setor', ['setor']) + .index('by_customizado', ['customizado']), + + permissoes: defineTable({ + nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc. + descricao: v.string(), + recurso: v.string(), // "funcionarios", "simbolos", "usuarios", etc. + acao: v.string() // "criar", "ler", "editar", "excluir" + }) + .index('by_recurso', ['recurso']) + .index('by_recurso_e_acao', ['recurso', 'acao']) + .index('by_nome', ['nome']), + + rolePermissoes: defineTable({ + roleId: v.id('roles'), + permissaoId: v.id('permissoes') + }) + .index('by_role', ['roleId']) + .index('by_permissao', ['permissaoId']), + + sessoes: defineTable({ + usuarioId: v.id('usuarios'), + token: v.string(), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + criadoEm: v.number(), + expiraEm: v.number(), + ativo: v.boolean() + }) + .index('by_usuario', ['usuarioId']) + .index('by_token', ['token']) + .index('by_ativo', ['ativo']) + .index('by_expiracao', ['expiraEm']), + + logsAcesso: defineTable({ + usuarioId: v.id('usuarios'), + tipo: v.union( + v.literal('login'), + v.literal('logout'), + v.literal('acesso_negado'), + v.literal('senha_alterada'), + v.literal('sessao_expirada') + ), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + detalhes: v.optional(v.string()), + timestamp: v.number() + }) + .index('by_usuario', ['usuarioId']) + .index('by_tipo', ['tipo']) + .index('by_timestamp', ['timestamp']), + + // Logs de Login Detalhados + logsLogin: defineTable({ + usuarioId: v.optional(v.id('usuarios')), // pode ser null se falha antes de identificar usuário + matriculaOuEmail: v.string(), // tentativa de login + sucesso: v.boolean(), + motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente" + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + device: v.optional(v.string()), + browser: v.optional(v.string()), + sistema: v.optional(v.string()), + timestamp: v.number() + }) + .index('by_usuario', ['usuarioId']) + .index('by_sucesso', ['sucesso']) + .index('by_timestamp', ['timestamp']) + .index('by_ip', ['ipAddress']), + + // Logs de Atividades + logsAtividades: defineTable({ + usuarioId: v.id('usuarios'), + acao: v.string(), // "criar", "editar", "excluir", "bloquear", "desbloquear", etc. + recurso: v.string(), // "funcionarios", "simbolos", "usuarios", "perfis", etc. + recursoId: v.optional(v.string()), // ID do recurso afetado + detalhes: v.optional(v.string()), // JSON com detalhes da ação + timestamp: v.number() + }) + .index('by_usuario', ['usuarioId']) + .index('by_acao', ['acao']) + .index('by_recurso', ['recurso']) + .index('by_timestamp', ['timestamp']) + .index('by_recurso_id', ['recurso', 'recursoId']), + + // Histórico de Bloqueios + bloqueiosUsuarios: defineTable({ + usuarioId: v.id('usuarios'), + motivo: v.string(), + bloqueadoPor: v.id('usuarios'), // ID do TI_MASTER que bloqueou + dataInicio: v.number(), + dataFim: v.optional(v.number()), // quando foi desbloqueado + desbloqueadoPor: v.optional(v.id('usuarios')), + ativo: v.boolean() // se é o bloqueio atual ativo + }) + .index('by_usuario', ['usuarioId']) + .index('by_bloqueado_por', ['bloqueadoPor']) + .index('by_ativo', ['ativo']) + .index('by_data_inicio', ['dataInicio']), + + // Perfis Customizados + + // Templates de Mensagens + templatesMensagens: defineTable({ + codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc. + nome: v.string(), + tipo: v.union( + v.literal('sistema'), // predefinido, não editável + v.literal('customizado') // criado por TI_MASTER + ), + titulo: v.string(), + corpo: v.string(), // pode ter variáveis {{variavel}} + variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.] + criadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number() + }) + .index('by_codigo', ['codigo']) + .index('by_tipo', ['tipo']) + .index('by_criado_por', ['criadoPor']), + + // Configuração de Email/SMTP + configuracaoEmail: defineTable({ + servidor: v.string(), // smtp.gmail.com + porta: v.number(), // 587, 465, etc. + usuario: v.string(), + senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP + emailRemetente: v.string(), + nomeRemetente: v.string(), + usarSSL: v.boolean(), + usarTLS: v.boolean(), + ativo: v.boolean(), + testadoEm: v.optional(v.number()), + configuradoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['ativo']), + + // Configuração de Jitsi Meet + configuracaoJitsi: defineTable({ + domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com") + appId: v.string(), // ID da aplicação Jitsi + roomPrefix: v.string(), // Prefixo para nomes de salas + useHttps: v.boolean(), // Usar HTTPS + acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento) + // Configurações SSH/Docker para configuração automática do servidor + sshHost: v.optional(v.string()), // Host SSH para acesso ao servidor Docker (ex: "192.168.1.100" ou "servidor.local") + sshPort: v.optional(v.number()), // Porta SSH (padrão: 22) + sshUsername: v.optional(v.string()), // Usuário SSH + sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada) + sshKeyPath: v.optional(v.string()), // Caminho para chave SSH (alternativa à senha) + dockerComposePath: v.optional(v.string()), // Caminho do docker-compose.yml (ex: "/home/user/jitsi-docker") + jitsiConfigPath: v.optional(v.string()), // Caminho base das configurações Jitsi (ex: "~/.jitsi-meet-cfg") + ativo: v.boolean(), // Configuração ativa + testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão + configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker + configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor + 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 + }).index('by_ativo', ['ativo']), + + // Fila de Emails + notificacoesEmail: defineTable({ + destinatario: v.string(), // email + destinatarioId: v.optional(v.id('usuarios')), + assunto: v.string(), + corpo: v.string(), // HTML ou texto + templateId: v.optional(v.id('templatesMensagens')), + status: v.union( + v.literal('pendente'), + v.literal('enviando'), + v.literal('enviado'), + v.literal('falha') + ), + tentativas: v.number(), + ultimaTentativa: v.optional(v.number()), + erroDetalhes: v.optional(v.string()), + enviadoPor: v.id('usuarios'), + criadoEm: v.number(), + enviadoEm: v.optional(v.number()), + agendadaPara: v.optional(v.number()) // timestamp para agendamento + }) + .index('by_status', ['status']) + .index('by_destinatario', ['destinatarioId']) + .index('by_enviado_por', ['enviadoPor']) + .index('by_criado_em', ['criadoEm']) + .index('by_agendamento', ['agendadaPara']), + + configuracaoAcesso: defineTable({ + chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc. + valor: v.string(), + descricao: v.string() + }).index('by_chave', ['chave']), + + // Rate Limiting de Emails + rateLimitEmails: defineTable({ + remetenteId: v.id('usuarios'), + timestamp: v.number(), + contador: v.number(), // quantidade de emails enviados neste período + periodo: v.union( + v.literal('minuto'), // último minuto + v.literal('hora') // última hora + ) + }) + .index('by_remetente_periodo', ['remetenteId', 'periodo', 'timestamp']) + .index('by_timestamp', ['timestamp']), + + // Sistema de Chat + conversas: defineTable({ + tipo: v.union(v.literal('individual'), v.literal('grupo'), v.literal('sala_reuniao')), + nome: v.optional(v.string()), // nome do grupo/sala + + participantes: v.array(v.id('usuarios')), // IDs dos participantes + administradores: v.optional(v.array(v.id('usuarios'))), // IDs dos administradores (apenas para sala_reuniao) + ultimaMensagem: v.optional(v.string()), + ultimaMensagemTimestamp: v.optional(v.number()), + ultimaMensagemRemetenteId: v.optional(v.id('usuarios')), // ID do remetente da última mensagem + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_criado_por', ['criadoPor']) + .index('by_tipo', ['tipo']) + .index('by_ultima_mensagem', ['ultimaMensagemTimestamp']), + + mensagens: defineTable({ + conversaId: v.id('conversas'), + remetenteId: v.id('usuarios'), + tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')), + conteudo: v.string(), // texto ou nome do arquivo + conteudoBusca: v.optional(v.string()), // versão normalizada para busca + arquivoId: v.optional(v.id('_storage')), + arquivoNome: v.optional(v.string()), + arquivoTamanho: v.optional(v.number()), + arquivoTipo: v.optional(v.string()), + linkPreview: v.optional( + v.object({ + url: v.string(), + titulo: v.optional(v.string()), + descricao: v.optional(v.string()), + imagem: v.optional(v.string()), + site: v.optional(v.string()) + }) + ), + reagiuPor: v.optional( + v.array( + v.object({ + usuarioId: v.id('usuarios'), + emoji: v.string() + }) + ) + ), + mencoes: v.optional(v.array(v.id('usuarios'))), + respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo + agendadaPara: v.optional(v.number()), // timestamp + enviadaEm: v.number(), + editadaEm: v.optional(v.number()), + deletada: v.optional(v.boolean()), + lidaPor: v.optional(v.array(v.id('usuarios'))) // IDs dos usuários que leram a mensagem + }) + .index('by_conversa', ['conversaId', 'enviadaEm']) + .index('by_remetente', ['remetenteId']) + .index('by_agendamento', ['agendadaPara']) + .index('by_resposta', ['respostaPara']), + + leituras: defineTable({ + conversaId: v.id('conversas'), + usuarioId: v.id('usuarios'), + ultimaMensagemLida: v.id('mensagens'), + lidaEm: v.number() + }) + .index('by_conversa_usuario', ['conversaId', 'usuarioId']) + .index('by_usuario', ['usuarioId']), + + // Sistema de Chamadas de Áudio/Vídeo + chamadas: defineTable({ + conversaId: v.id('conversas'), + tipo: v.union(v.literal('audio'), v.literal('video')), + roomName: v.string(), // Nome único da sala Jitsi + criadoPor: v.id('usuarios'), // Anfitrião/criador + participantes: v.array(v.id('usuarios')), + status: v.union( + v.literal('aguardando'), + v.literal('em_andamento'), + v.literal('finalizada'), + v.literal('cancelada') + ), + iniciadaEm: v.optional(v.number()), + finalizadaEm: v.optional(v.number()), + duracaoSegundos: v.optional(v.number()), + gravando: v.boolean(), + gravacaoIniciadaPor: v.optional(v.id('usuarios')), + gravacaoIniciadaEm: v.optional(v.number()), + gravacaoFinalizadaEm: v.optional(v.number()), + configuracoes: v.optional( + v.object({ + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + participantesConfig: v.optional( + v.array( + v.object({ + usuarioId: v.id('usuarios'), + audioHabilitado: v.boolean(), + videoHabilitado: v.boolean(), + forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião + }) + ) + ) + }) + ), + criadoEm: v.number() + }) + .index('by_conversa', ['conversaId', 'status']) + .index('by_criado_por', ['criadoPor']) + .index('by_status', ['status']) + .index('by_room_name', ['roomName']), + + notificacoes: defineTable({ + usuarioId: v.id('usuarios'), + tipo: v.union( + v.literal('nova_mensagem'), + v.literal('mencao'), + v.literal('grupo_criado'), + v.literal('adicionado_grupo'), + v.literal('alerta_seguranca'), + v.literal('etapa_fluxo_concluida') + ), + conversaId: v.optional(v.id('conversas')), + mensagemId: v.optional(v.id('mensagens')), + remetenteId: v.optional(v.id('usuarios')), + titulo: v.string(), + descricao: v.string(), + lida: v.boolean(), + criadaEm: v.number() + }) + .index('by_usuario', ['usuarioId', 'lida', 'criadaEm']) + .index('by_usuario_lida', ['usuarioId', 'lida']), + + digitando: defineTable({ + conversaId: v.id('conversas'), + usuarioId: v.id('usuarios'), + iniciouEm: v.number() + }) + .index('by_conversa', ['conversaId', 'iniciouEm']) + .index('by_usuario', ['usuarioId']), + + // Push Notifications + pushSubscriptions: defineTable({ + usuarioId: v.id('usuarios'), + endpoint: v.string(), // URL do serviço de push + keys: v.object({ + p256dh: v.string(), // Chave pública + auth: v.string() // Chave de autenticação + }), + userAgent: v.optional(v.string()), + criadoEm: v.number(), + ultimaAtividade: v.number(), + ativo: v.boolean() + }) + .index('by_usuario', ['usuarioId', 'ativo']) + .index('by_endpoint', ['endpoint']), + + // Preferências de Notificação por Conversa + preferenciasNotificacaoConversa: defineTable({ + usuarioId: v.id('usuarios'), + conversaId: v.id('conversas'), + pushAtivado: v.boolean(), // Receber push notifications + emailAtivado: v.boolean(), // Receber emails quando offline + somAtivado: v.boolean(), // Tocar som + silenciado: v.boolean(), // Silenciar completamente + apenasMencoes: v.boolean(), // Notificar apenas quando mencionado + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_usuario_conversa', ['usuarioId', 'conversaId']) + .index('by_conversa', ['conversaId']), + + // Tabelas de Monitoramento do Sistema + systemMetrics: defineTable({ + timestamp: v.number(), + // Métricas de Sistema + cpuUsage: v.optional(v.number()), + memoryUsage: v.optional(v.number()), + networkLatency: v.optional(v.number()), + storageUsed: v.optional(v.number()), + // Métricas de Aplicação + usuariosOnline: v.optional(v.number()), + mensagensPorMinuto: v.optional(v.number()), + tempoRespostaMedio: v.optional(v.number()), + errosCount: v.optional(v.number()) + }).index('by_timestamp', ['timestamp']), + + alertConfigurations: defineTable({ + metricName: v.string(), + threshold: v.number(), + operator: v.union( + v.literal('>'), + v.literal('<'), + v.literal('>='), + v.literal('<='), + v.literal('==') + ), + enabled: v.boolean(), + notifyByEmail: v.boolean(), + notifyByChat: v.boolean(), + createdBy: v.id('usuarios'), + lastModified: v.number() + }).index('by_enabled', ['enabled']), + + alertHistory: defineTable({ + configId: v.id('alertConfigurations'), + metricName: v.string(), + metricValue: v.number(), + threshold: v.number(), + timestamp: v.number(), + status: v.union(v.literal('triggered'), v.literal('resolved')), + notificationsSent: v.object({ + email: v.boolean(), + chat: v.boolean() + }) + }) + .index('by_timestamp', ['timestamp']) + .index('by_status', ['status']) + .index('by_config', ['configId', 'timestamp']), + + tickets: defineTable({ + numero: v.string(), + titulo: v.string(), + descricao: v.string(), + tipo: v.union( + v.literal('reclamacao'), + v.literal('elogio'), + v.literal('sugestao'), + v.literal('chamado') + ), + categoria: v.optional(v.string()), + status: v.union( + v.literal('aberto'), + v.literal('em_andamento'), + v.literal('aguardando_usuario'), + v.literal('resolvido'), + v.literal('encerrado'), + v.literal('cancelado') + ), + prioridade: v.union( + v.literal('baixa'), + v.literal('media'), + v.literal('alta'), + v.literal('critica') + ), + solicitanteId: v.id('usuarios'), + solicitanteNome: v.string(), + solicitanteEmail: v.string(), + responsavelId: v.optional(v.id('usuarios')), + setorResponsavel: v.optional(v.string()), + slaConfigId: v.optional(v.id('slaConfigs')), + conversaId: v.optional(v.id('conversas')), + prazoResposta: v.optional(v.number()), + prazoConclusao: v.optional(v.number()), + prazoEncerramento: v.optional(v.number()), + timeline: v.optional( + v.array( + v.object({ + etapa: v.string(), + status: v.union( + v.literal('pendente'), + v.literal('em_andamento'), + v.literal('concluido'), + v.literal('vencido') + ), + prazo: v.optional(v.number()), + concluidoEm: v.optional(v.number()), + observacao: v.optional(v.string()) + }) + ) + ), + alertasEmitidos: v.optional( + v.array( + v.object({ + tipo: v.union(v.literal('resposta'), v.literal('conclusao'), v.literal('encerramento')), + emitidoEm: v.number() + }) + ) + ), + anexos: v.optional( + v.array( + v.object({ + arquivoId: v.id('_storage'), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()) + }) + ) + ), + tags: v.optional(v.array(v.string())), + canalOrigem: v.optional(v.string()), + ultimaInteracaoEm: v.number(), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_numero', ['numero']) + .index('by_status', ['status']) + .index('by_solicitante', ['solicitanteId', 'status']) + .index('by_responsavel', ['responsavelId', 'status']) + .index('by_setor', ['setorResponsavel', 'status']), + + ticketInteractions: defineTable({ + ticketId: v.id('tickets'), + autorId: v.optional(v.id('usuarios')), + origem: v.union(v.literal('usuario'), v.literal('ti'), v.literal('sistema')), + tipo: v.union( + v.literal('mensagem'), + v.literal('status'), + v.literal('anexo'), + v.literal('alerta') + ), + conteudo: v.string(), + anexos: v.optional( + v.array( + v.object({ + arquivoId: v.id('_storage'), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()) + }) + ) + ), + statusAnterior: v.optional( + v.union( + v.literal('aberto'), + v.literal('em_andamento'), + v.literal('aguardando_usuario'), + v.literal('resolvido'), + v.literal('encerrado'), + v.literal('cancelado') + ) + ), + statusNovo: v.optional( + v.union( + v.literal('aberto'), + v.literal('em_andamento'), + v.literal('aguardando_usuario'), + v.literal('resolvido'), + v.literal('encerrado'), + v.literal('cancelado') + ) + ), + visibilidade: v.union(v.literal('publico'), v.literal('interno')), + criadoEm: v.number() + }) + .index('by_ticket', ['ticketId']) + .index('by_ticket_type', ['ticketId', 'tipo']) + .index('by_autor', ['autorId']), + + slaConfigs: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + prioridade: v.optional( + v.union(v.literal('baixa'), v.literal('media'), v.literal('alta'), v.literal('critica')) + ), + tempoRespostaHoras: v.number(), + tempoConclusaoHoras: v.number(), + tempoEncerramentoHoras: v.optional(v.number()), + alertaAntecedenciaHoras: v.number(), + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_ativo', ['ativo']) + .index('by_prioridade', ['prioridade', 'ativo']) + .index('by_nome', ['nome']), + + ticketAssignments: defineTable({ + ticketId: v.id('tickets'), + responsavelId: v.id('usuarios'), + atribuidoPor: v.id('usuarios'), + motivo: v.optional(v.string()), + ativo: v.boolean(), + criadoEm: v.number(), + encerradoEm: v.optional(v.number()) + }) + .index('by_ticket', ['ticketId', 'ativo']) + .index('by_responsavel', ['responsavelId', 'ativo']), + + // Sistema de Segurança Cibernética + networkSensors: defineTable({ + nome: v.string(), + tipo: sensorSegurancaTipo, + status: sensorSegurancaStatus, + escopo: v.optional(v.string()), + ipMonitorado: v.optional(v.string()), + hostname: v.optional(v.string()), + regioes: v.optional(v.array(v.string())), + portasMonitoradas: v.optional(v.array(v.number())), + protocolos: v.optional(v.array(v.string())), + capacidades: v.optional(v.array(v.string())), + ultimaSincronizacao: v.number(), + ultimoHeartbeat: v.optional(v.number()), + latenciaMs: v.optional(v.number()), + errosConsecutivos: v.optional(v.number()), + agenteVersao: v.optional(v.string()), + notas: v.optional(v.string()) + }) + .index('by_tipo', ['tipo']) + .index('by_status', ['status']) + .index('by_hostname', ['hostname']), + + ipReputation: defineTable({ + indicador: v.string(), + categoria: v.union( + v.literal('ip'), + v.literal('dominio'), + v.literal('hash'), + v.literal('email') + ), + reputacao: v.number(), // -100 (malicioso) até 100 (confiável) + severidadeMax: severidadeSeguranca, + whitelist: v.boolean(), + blacklist: v.boolean(), + ocorrencias: v.number(), + primeiroRegistro: v.number(), + ultimoRegistro: v.number(), + bloqueadoAte: v.optional(v.number()), + origem: v.optional(v.string()), + comentarios: v.optional(v.string()), + classificacoes: v.optional(v.array(v.string())), + ultimaAcaoId: v.optional(v.id('incidentActions')) + }) + .index('by_indicador', ['indicador']) + .index('by_reputacao', ['reputacao']) + .index('by_blacklist', ['blacklist']) + .index('by_whitelist', ['whitelist']), + + portRules: defineTable({ + porta: v.number(), + protocolo: v.union( + v.literal('tcp'), + v.literal('udp'), + v.literal('icmp'), + v.literal('quic'), + v.literal('any') + ), + acao: v.union( + v.literal('permitir'), + v.literal('bloquear'), + v.literal('monitorar'), + v.literal('rate_limit') + ), + temporario: v.boolean(), + severidadeMin: severidadeSeguranca, + duracaoSegundos: v.optional(v.number()), + expiraEm: v.optional(v.number()), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number(), + notas: v.optional(v.string()), + tags: v.optional(v.array(v.string())), + listaReferencia: v.optional(v.id('ipReputation')) + }) + .index('by_porta_protocolo', ['porta', 'protocolo']) + .index('by_acao', ['acao']) + .index('by_expiracao', ['expiraEm']), + + threatIntelFeeds: defineTable({ + nomeFonte: v.string(), + tipo: threatIntelTipo, + formato: threatIntelFormato, + url: v.optional(v.string()), + ativo: v.boolean(), + prioridade: v.union( + v.literal('baixa'), + v.literal('media'), + v.literal('alta'), + v.literal('critica') + ), + ultimaSincronizacao: v.optional(v.number()), + entradasProcessadas: v.optional(v.number()), + errosConsecutivos: v.optional(v.number()), + autenticacaoNecessaria: v.optional(v.boolean()), + configuracao: v.optional( + v.object({ + tokenId: v.optional(v.id('_storage')), + escopo: v.optional(v.string()) + }) + ), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_tipo', ['tipo']) + .index('by_ativo', ['ativo']) + .index('by_prioridade', ['prioridade']), + + securityEvents: defineTable({ + referencia: v.string(), + timestamp: v.number(), + tipoAtaque: ataqueCiberneticoTipo, + severidade: severidadeSeguranca, + status: statusEventoSeguranca, + descricao: v.string(), + origemIp: v.optional(v.string()), + origemRegiao: v.optional(v.string()), + origemAsn: v.optional(v.string()), + destinoIp: v.optional(v.string()), + destinoPorta: v.optional(v.number()), + protocolo: v.optional(v.string()), + transporte: v.optional(v.string()), + sensorId: v.optional(v.id('networkSensors')), + detectadoPor: v.optional(v.string()), + mitreTechnique: v.optional(v.string()), + geolocalizacao: v.optional( + v.object({ + pais: v.optional(v.string()), + regiao: v.optional(v.string()), + cidade: v.optional(v.string()), + latitude: v.optional(v.number()), + longitude: v.optional(v.number()) + }) + ), + fingerprint: v.optional( + v.object({ + userAgent: v.optional(v.string()), + deviceId: v.optional(v.string()), + ja3: v.optional(v.string()), + tlsVersion: v.optional(v.string()) + }) + ), + indicadores: v.optional( + v.array( + v.object({ + tipo: v.string(), + valor: v.string(), + confianca: v.optional(v.number()) + }) + ) + ), + metricas: v.optional( + v.object({ + pps: v.optional(v.number()), + bps: v.optional(v.number()), + rpm: v.optional(v.number()), + errosPorSegundo: v.optional(v.number()), + hostsAfetados: v.optional(v.number()) + }) + ), + correlacoes: v.optional(v.array(v.id('securityEvents'))), + referenciasExternas: v.optional(v.array(v.string())), + tags: v.optional(v.array(v.string())), + criadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.number() + }) + .index('by_referencia', ['referencia']) + .index('by_timestamp', ['timestamp']) + .index('by_tipo', ['tipoAtaque', 'timestamp']) + .index('by_severidade', ['severidade', 'timestamp']) + .index('by_status', ['status', 'timestamp']), + + incidentActions: defineTable({ + eventoId: v.id('securityEvents'), + tipo: acaoIncidenteTipo, + origem: v.union(v.literal('automatico'), v.literal('manual')), + status: acaoIncidenteStatus, + executadoPor: v.optional(v.id('usuarios')), + detalhes: v.optional(v.string()), + resultado: v.optional(v.string()), + relacionadoA: v.optional(v.id('ipReputation')), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_evento', ['eventoId', 'status']) + .index('by_tipo', ['tipo', 'status']), + + reportRequests: defineTable({ + solicitanteId: v.id('usuarios'), + filtros: v.object({ + dataInicio: v.number(), + dataFim: v.number(), + severidades: v.optional(v.array(severidadeSeguranca)), + tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), + incluirIndicadores: v.optional(v.boolean()), + incluirMetricas: v.optional(v.boolean()), + incluirAcoes: v.optional(v.boolean()) + }), + status: reportStatus, + resultadoId: v.optional(v.id('_storage')), + observacoes: v.optional(v.string()), + criadoEm: v.number(), + atualizadoEm: v.number(), + concluidoEm: v.optional(v.number()), + erro: v.optional(v.string()) + }) + .index('by_status', ['status']) + .index('by_solicitante', ['solicitanteId', 'status']) + .index('by_criado_em', ['criadoEm']), + + rateLimitConfig: defineTable({ + nome: v.string(), + tipo: v.union( + v.literal('ip'), + v.literal('usuario'), + v.literal('endpoint'), + v.literal('global') + ), + identificador: v.optional(v.string()), + limite: v.number(), + janelaSegundos: v.number(), + estrategia: v.union( + v.literal('fixed_window'), + v.literal('sliding_window'), + v.literal('token_bucket') + ), + acaoExcedido: v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar')), + bloqueioTemporarioSegundos: v.optional(v.number()), + ativo: v.boolean(), + prioridade: v.number(), + criadoPor: v.id('usuarios'), + atualizadoPor: v.optional(v.id('usuarios')), + criadoEm: v.number(), + atualizadoEm: v.number(), + notas: v.optional(v.string()), + tags: v.optional(v.array(v.string())) + }) + .index('by_tipo_identificador', ['tipo', 'identificador']) + .index('by_ativo', ['ativo']) + .index('by_prioridade', ['prioridade']), + alertConfigs: defineTable({ + nome: v.string(), + canais: v.object({ + email: v.boolean(), + chat: v.boolean() + }), + emails: v.array(v.string()), + chatUsers: v.array(v.string()), + severidadeMin: severidadeSeguranca, + tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)), + reenvioMin: v.number(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }).index('by_criadoEm', ['criadoEm']), + + // Sistema de Controle de Ponto + registrosPonto: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union( + v.literal('entrada'), + v.literal('saida_almoco'), + v.literal('retorno_almoco'), + v.literal('saida') + ), + data: v.string(), // YYYY-MM-DD + hora: v.number(), + minuto: v.number(), + segundo: v.number(), + timestamp: v.number(), // Timestamp completo para ordenação + imagemId: v.optional(v.id('_storage')), + sincronizadoComServidor: v.boolean(), + toleranciaMinutos: v.number(), + dentroDoPrazo: v.boolean(), + + // Informações de Rede + ipAddress: v.optional(v.string()), + ipPublico: v.optional(v.string()), + ipLocal: v.optional(v.string()), + + // Informações do Navegador + userAgent: v.optional(v.string()), + browser: v.optional(v.string()), + browserVersion: v.optional(v.string()), + engine: v.optional(v.string()), + + // Informações do Sistema + sistemaOperacional: v.optional(v.string()), + osVersion: v.optional(v.string()), + arquitetura: v.optional(v.string()), + plataforma: v.optional(v.string()), + + // Informações de Localização + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), + precisao: v.optional(v.number()), + altitude: v.optional(v.union(v.number(), v.null())), + altitudeAccuracy: v.optional(v.union(v.number(), v.null())), + heading: v.optional(v.union(v.number(), v.null())), + speed: v.optional(v.union(v.number(), v.null())), + confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend) + scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend) + suspeitaSpoofing: v.optional(v.boolean()), + motivoSuspeita: v.optional(v.string()), + avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação + distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS + velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro + distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro + tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro + // Informações de Geofencing + enderecoMarcacaoEsperado: v.optional(v.id('enderecosMarcacao')), // Endereço mais próximo esperado + distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado + dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido + enderecoMarcacaoUsado: v.optional(v.id('enderecosMarcacao')), // Qual endereço foi usado na validação + raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros + endereco: v.optional(v.string()), + cidade: v.optional(v.string()), + estado: v.optional(v.string()), + pais: v.optional(v.string()), + timezone: v.optional(v.string()), + + // Informações do Dispositivo + deviceType: v.optional(v.string()), + deviceModel: v.optional(v.string()), + screenResolution: v.optional(v.string()), + coresTela: v.optional(v.number()), + idioma: v.optional(v.string()), + + // Informações Adicionais + isMobile: v.optional(v.boolean()), + isTablet: v.optional(v.boolean()), + isDesktop: v.optional(v.boolean()), + connectionType: v.optional(v.string()), + memoryInfo: v.optional(v.string()), + + // Informações de Sensores (Acelerômetro e Giroscópio) + acelerometroX: v.optional(v.number()), + acelerometroY: v.optional(v.number()), + acelerometroZ: v.optional(v.number()), + movimentoDetectado: v.optional(v.boolean()), + magnitudeMovimento: v.optional(v.number()), + variacaoAcelerometro: v.optional(v.number()), + giroscopioAlpha: v.optional(v.number()), + giroscopioBeta: v.optional(v.number()), + giroscopioGamma: v.optional(v.number()), + sensorDisponivel: v.optional(v.boolean()), + permissaoSensorNegada: v.optional(v.boolean()), + + // Justificativa opcional para o registro + justificativa: v.optional(v.string()), + + // Campos para homologação + editadoPorGestor: v.optional(v.boolean()), + homologacaoId: v.optional(v.id('homologacoesPonto')), + + criadoEm: v.number() + }) + .index('by_funcionario_data', ['funcionarioId', 'data']) + .index('by_data', ['data']) + .index('by_dentro_prazo', ['dentroDoPrazo', 'data']) + .index('by_funcionario_timestamp', ['funcionarioId', 'timestamp']), + + // Endereços de Marcação - Locais permitidos para registro de ponto + enderecosMarcacao: defineTable({ + nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC" + descricao: v.optional(v.string()), // Descrição opcional + // Coordenadas (obrigatórias) + latitude: v.number(), + longitude: v.number(), + // Endereço físico (para exibição) + endereco: v.string(), // Ex: "Rua Exemplo, 123" + bairro: v.optional(v.string()), // Bairro do endereço + cep: v.optional(v.string()), + cidade: v.string(), + estado: v.string(), + pais: v.optional(v.string()), // Padrão: "Brasil" + // Configurações + raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m) + ativo: v.boolean(), + // Tipos de uso + tipo: v.union( + v.literal('sede'), // Sede principal (para todos) + v.literal('home_office'), // Home office específico + v.literal('deslocamento'), // Deslocamento temporário + v.literal('cliente') // Local de cliente + ), + // Metadados + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.optional(v.number()) + }) + .index('by_ativo', ['ativo']) + .index('by_tipo', ['tipo']) + .index('by_cidade', ['cidade']), + + // Associação Funcionário ↔ Endereço de Marcação + funcionarioEnderecosMarcacao: defineTable({ + funcionarioId: v.id('funcionarios'), + enderecoMarcacaoId: v.id('enderecosMarcacao'), + // Configurações específicas do funcionário + raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão + // Período de validade (para deslocamentos temporários) + dataInicio: v.optional(v.string()), // YYYY-MM-DD + dataFim: v.optional(v.string()), // YYYY-MM-DD + // Status + ativo: v.boolean(), + // Metadados + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_endereco', ['enderecoMarcacaoId']) + .index('by_funcionario_ativo', ['funcionarioId', 'ativo']) + .index('by_endereco_ativo', ['enderecoMarcacaoId', 'ativo']), + + configuracaoPonto: defineTable({ + horarioEntrada: v.string(), // HH:mm + horarioSaidaAlmoco: v.string(), // HH:mm + horarioRetornoAlmoco: v.string(), // HH:mm + horarioSaida: v.string(), // HH:mm + toleranciaMinutos: v.number(), + // Nomes personalizados dos tipos de registro + nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1" + nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1" + nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2" + nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" + // Ajuste de fuso horário (GMT offset em horas) + gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + // Configurações de geofencing + validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização + toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros + ativo: v.boolean(), + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['ativo']), + + configuracaoRelogio: defineTable({ + servidorNTP: v.optional(v.string()), + portaNTP: v.optional(v.number()), + usarServidorExterno: v.boolean(), + fallbackParaPC: v.boolean(), + ultimaSincronizacao: v.optional(v.number()), + offsetSegundos: v.optional(v.number()), + // Ajuste de fuso horário (GMT offset em horas) + gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['usarServidorExterno']), + + // Banco de Horas - Saldo diário de horas trabalhadas + bancoHoras: defineTable({ + funcionarioId: v.id('funcionarios'), + data: v.string(), // YYYY-MM-DD + cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) + horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) + saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) + registrosPontoIds: v.array(v.id('registrosPonto')), // IDs dos registros do dia + calculadoEm: v.number() + }) + .index('by_funcionario_data', ['funcionarioId', 'data']) + .index('by_funcionario', ['funcionarioId']) + .index('by_data', ['data']), + + // Homologações de Ponto - Edições e ajustes realizados pelo gestor + homologacoesPonto: defineTable({ + registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição) + funcionarioId: v.id('funcionarios'), + gestorId: v.id('usuarios'), + // Dados do registro original (se for edição) + horaAnterior: v.optional(v.number()), + minutoAnterior: v.optional(v.number()), + // Dados do registro novo (se for edição) + horaNova: v.optional(v.number()), + minutoNova: v.optional(v.number()), + // Motivo e observações + motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações) + motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc) + motivoDescricao: v.optional(v.string()), // Descrição do motivo + observacoes: v.optional(v.string()), + // Tipo de ajuste (se for ajuste de banco de horas) + tipoAjuste: v.optional( + v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')) + ), + // Período do ajuste (se for ajuste de banco de horas) + periodoDias: v.optional(v.number()), + periodoHoras: v.optional(v.number()), + periodoMinutos: v.optional(v.number()), + // Ajuste em minutos (calculado) + ajusteMinutos: v.optional(v.number()), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_gestor', ['gestorId']) + .index('by_registro', ['registroId']) + .index('by_data', ['criadoEm']), + + // Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto + dispensasRegistro: defineTable({ + funcionarioId: v.id('funcionarios'), + gestorId: v.id('usuarios'), + dataInicio: v.string(), // YYYY-MM-DD + horaInicio: v.number(), + minutoInicio: v.number(), + dataFim: v.string(), // YYYY-MM-DD + horaFim: v.number(), + minutoFim: v.number(), + motivo: v.string(), + isento: v.boolean(), // Se true, não expira (casos excepcionais) + ativo: v.boolean(), + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_gestor', ['gestorId']) + .index('by_ativo', ['ativo']) + .index('by_data_inicio', ['dataInicio']) + .index('by_data_fim', ['dataFim']), + // Configurações Gerais + config: defineTable({ + comprasSetorId: v.optional(v.id('setores')), + criadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }), + + // Módulo de Pedidos/Compras + produtos: defineTable({ + nome: v.string(), + valorEstimado: v.string(), + tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .searchIndex('search_nome', { searchField: 'nome' }) + .index('by_nome', ['nome']) + .index('by_tipo', ['tipo']), + + acoes: defineTable({ + nome: v.string(), + tipo: v.union(v.literal('projeto'), v.literal('lei')), + criadoPor: v.id('usuarios'), + criadoEm: v.number() + }) + .index('by_nome', ['nome']) + .index('by_tipo', ['tipo']), + + pedidos: defineTable({ + numeroSei: v.optional(v.string()), + status: v.union( + v.literal('em_rascunho'), + v.literal('aguardando_aceite'), + v.literal('em_analise'), + v.literal('precisa_ajustes'), + v.literal('cancelado'), + v.literal('concluido') + ), + acaoId: v.optional(v.id('acoes')), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_numeroSei', ['numeroSei']) + .index('by_status', ['status']) + .index('by_criadoPor', ['criadoPor']) + .index('by_acaoId', ['acaoId']), + + pedidoItems: defineTable({ + pedidoId: v.id('pedidos'), + produtoId: v.id('produtos'), + valorEstimado: v.string(), + valorReal: v.optional(v.string()), + quantidade: v.number(), + adicionadoPor: v.id('funcionarios'), + criadoEm: v.number() + }) + .index('by_pedidoId', ['pedidoId']) + .index('by_produtoId', ['produtoId']) + .index('by_adicionadoPor', ['adicionadoPor']), + + historicoPedidos: defineTable({ + pedidoId: v.id('pedidos'), + usuarioId: v.id('usuarios'), + acao: v.string(), // "criacao", "alteracao_status", "adicao_item", "remocao_item", "edicao_item" + detalhes: v.optional(v.string()), // JSON string + data: v.number() + }) + .index('by_pedidoId', ['pedidoId']) + .index('by_usuarioId', ['usuarioId']) + .index('by_data', ['data']) }); -- 2.49.1 From 6e836e9eb5b0bf8a674947c647e4ef654b9b0602 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 19:54:33 -0300 Subject: [PATCH 032/138] 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) */ -- 2.49.1 From c19c8c859e06491fa289b6b3c044fae2ac3aff4c Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 22:04:32 -0300 Subject: [PATCH 033/138] feat: add setores display and loading state to perfil page, and implement click outside functionality for dropdown menus in funcionarios page --- .../routes/(dashboard)/perfil/+page.svelte | 42 ++++++++++++ .../funcionarios/+page.svelte | 66 +++++++++++++++++-- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index 5c162ca..b7438ba 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -199,6 +199,13 @@ }); }); + const setoresQuery = $derived.by(() => { + if (!funcionarioIdParaQueries) return { data: [] }; + return useQuery(api.setores.getSetoresByFuncionario, { + funcionarioId: funcionarioIdParaQueries + }); + }); + $effect(() => { if (meuTimeQuery?.data && funcionarioIdParaQueries) { meuTimeEstavel = meuTimeQuery.data; @@ -1186,6 +1193,41 @@
+
+
+ +
+
+ Setores + {#if setoresQuery?.data && setoresQuery.data.length > 0} +
+ {#each setoresQuery.data as setor} +
+ {setor.nome} + {#if setor.sigla} + ({setor.sigla}) + {/if} +
+ {/each} +
+ {:else if setoresQuery?.isLoading} +

Carregando...

+ {:else} +

Nenhum setor atribuído

+ {/if} +
+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte index ca05422..c46f962 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte @@ -79,6 +79,42 @@ openMenuId = openMenuId === id ? null : id; } + function closeMenu() { + openMenuId = null; + } + + // Fechar menu ao clicar fora + $effect(() => { + if (!openMenuId) return; + + function handleClickOutside(event: MouseEvent) { + const target = event.target as HTMLElement; + // Verificar se o clique foi fora do dropdown (botão e menu) + const dropdown = target.closest('.dropdown'); + if (!dropdown) { + openMenuId = null; + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + openMenuId = null; + } + } + + // Adicionar listeners no próximo tick para não interferir com o clique que abriu o menu + const timeoutId = setTimeout(() => { + document.addEventListener('click', handleClickOutside); + document.addEventListener('keydown', handleEscape); + }, 10); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('click', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }); + async function openSetoresModal(funcionarioId: Id<'funcionarios'>, nome: string) { funcionarioParaSetores = { _id: funcionarioId, nome }; setoresSelecionados = []; @@ -382,30 +418,50 @@ class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl" >
  • - + Ver Detalhes
  • - + Editar
  • - + Ver Documentos
  • -
  • -- 2.49.1 From 95c3b48ae63884fb2009ee5119d38913f777c4fc Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 1 Dec 2025 22:13:01 -0300 Subject: [PATCH 034/138] feat: add UserAvatar component to display employee profile pictures in various HR pages, enhancing visual representation of employee data --- .../atestados-licencas/+page.svelte | 23 ++++++++- .../controle-ponto/dispensa/+page.svelte | 45 +++++++++++------ .../controle-ponto/homologacao/+page.svelte | 50 +++++++++++++------ packages/backend/convex/atestadosLicencas.ts | 30 +++++++++++ packages/backend/convex/pontos.ts | 49 +++++++++++++++++- packages/backend/convex/times.ts | 15 +++++- 6 files changed, 178 insertions(+), 34 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 8f255b8..8c52c8c 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 @@ -11,6 +11,7 @@ import ErrorModal from '$lib/components/ErrorModal.svelte'; import CalendarioAfastamentos from '$lib/components/CalendarioAfastamentos.svelte'; import AreaChart from '$lib/components/ti/charts/AreaChart.svelte'; + import UserAvatar from '$lib/components/chat/UserAvatar.svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import jsPDF from 'jspdf'; import autoTable from 'jspdf-autotable'; @@ -1578,7 +1579,16 @@ {#each registrosFiltrados.atestados as atestado} - {atestado.funcionario?.nome || '-'} + +
    + + {atestado.funcionario?.nome || '-'} +
    + + + {licenca.funcionario?.nome || '-'} +
    + {#each funcionarios as funcionario}
    -
    - -

    - Atenção: O aceite deste termo é obrigatório para utilização do - sistema. Ao aceitar, você confirma que leu, compreendeu e concorda com todos os - termos e condições estabelecidos. -

    -
    + {#if termoObrigatorio} +
    + +

    + Atenção: O aceite deste termo é obrigatório para utilização do + sistema. Ao aceitar, você confirma que leu, compreendeu e concorda com todos os + termos e condições estabelecidos. +

    +
    + {:else} +
    + +

    + Informação: O aceite deste termo é opcional. Você pode aceitar + voluntariamente ou continuar sem aceitar. +

    +
    + {/if}
    @@ -253,7 +273,7 @@
    - {#if estatisticas} + {#if estatisticas?.data}

    Solicitações por Tipo

    - {#each Object.entries(estatisticas.solicitacoesPorTipo) as [tipo, quantidade]} + {#each Object.entries(estatisticas.data.solicitacoesPorTipo) as [tipo, quantidade]}
    {tipo} {quantidade} @@ -111,19 +123,19 @@
    Total de ROTs: - {estatisticas.totalROTs} + {estatisticas.data.totalROTs}
    ROTs Ativos: - {estatisticas.rotsAtivos} + {estatisticas.data.rotsAtivos}
    Total de Consentimentos: - {estatisticas.totalConsentimentos} + {estatisticas.data.totalConsentimentos}
    Consentimentos Ativos: - {estatisticas.consentimentosAtivos} + {estatisticas.data.consentimentosAtivos}
    diff --git a/apps/web/src/routes/(dashboard)/ti/lgpd/configuracoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/lgpd/configuracoes/+page.svelte index 5189eb7..e663040 100644 --- a/apps/web/src/routes/(dashboard)/ti/lgpd/configuracoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/lgpd/configuracoes/+page.svelte @@ -2,7 +2,7 @@ import { resolve } from '$app/paths'; import { useQuery, useMutation } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Shield, Save, Mail, Phone, User, Calendar } from 'lucide-svelte'; + import { Shield, Save, Mail, Phone, User, Calendar, ToggleLeft, ToggleRight } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; const config = useQuery(api.lgpd.obterConfiguracaoLGPD, {}); @@ -11,8 +11,11 @@ let encarregadoNome = $state(''); let encarregadoEmail = $state(''); let encarregadoTelefone = $state(''); + let encarregadoHorarioAtendimento = $state('Segunda a Sexta, das 8h às 17h'); let prazoRespostaPadrao = $state(15); let diasAlertaVencimento = $state(3); + let termoObrigatorio = $state(false); + let versaoTermoAtual = $state('1.0'); let carregando = $state(false); // Sincronizar com query @@ -21,8 +24,11 @@ encarregadoNome = config.data.encarregadoNome || ''; encarregadoEmail = config.data.encarregadoEmail || ''; encarregadoTelefone = config.data.encarregadoTelefone || ''; + encarregadoHorarioAtendimento = config.data.encarregadoHorarioAtendimento || 'Segunda a Sexta, das 8h às 17h'; prazoRespostaPadrao = config.data.prazoRespostaPadrao; diasAlertaVencimento = config.data.diasAlertaVencimento; + termoObrigatorio = config.data.termoObrigatorio; + versaoTermoAtual = config.data.versaoTermoAtual; } }); @@ -34,13 +40,17 @@ encarregadoNome: encarregadoNome || undefined, encarregadoEmail: encarregadoEmail || undefined, encarregadoTelefone: encarregadoTelefone || undefined, + encarregadoHorarioAtendimento: encarregadoHorarioAtendimento || undefined, prazoRespostaPadrao, - diasAlertaVencimento + diasAlertaVencimento, + termoObrigatorio, + versaoTermoAtual: versaoTermoAtual || '1.0' }); toast.success('Configurações salvas com sucesso!'); - } catch (error: any) { - toast.error(error.message || 'Erro ao salvar configurações'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro ao salvar configurações'; + toast.error(message); } finally { carregando = false; } @@ -120,6 +130,73 @@ />
    + +
    + +
    + + +
    + +
    +
    +
    +
    + + +
    +
    +

    Termo de Consentimento

    +

    + Configure se o termo de consentimento é obrigatório para acesso ao sistema. +

    + +
    +
    + +
    + +
    + + + +
    diff --git a/apps/web/src/routes/(dashboard)/ti/lgpd/registros-tratamento/+page.svelte b/apps/web/src/routes/(dashboard)/ti/lgpd/registros-tratamento/+page.svelte index 8f0ce77..5d17efc 100644 --- a/apps/web/src/routes/(dashboard)/ti/lgpd/registros-tratamento/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/lgpd/registros-tratamento/+page.svelte @@ -87,8 +87,9 @@ compartilhamentoTerceiros = false; terceiros = []; descricao = ''; - } catch (error: any) { - toast.error(error.message || 'Erro ao criar registro'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro ao criar registro'; + toast.error(message); } finally { carregando = false; } diff --git a/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte index 57027d6..eb52064 100644 --- a/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/lgpd/solicitacoes/+page.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { useQuery, useMutation } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; + import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { Shield, FileText, @@ -106,7 +107,7 @@ try { await responderSolicitacao({ - solicitacaoId: solicitacaoSelecionada as any, + solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>, resposta: resposta.trim(), status: statusResposta }); @@ -114,8 +115,9 @@ toast.success('Solicitação respondida com sucesso!'); solicitacaoSelecionada = null; resposta = ''; - } catch (error: any) { - toast.error(error.message || 'Erro ao responder solicitação'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Erro ao responder solicitação'; + toast.error(message); } finally { carregando = false; } diff --git a/packages/backend/convex/lgpd.ts b/packages/backend/convex/lgpd.ts index b3598c0..1740bff 100644 --- a/packages/backend/convex/lgpd.ts +++ b/packages/backend/convex/lgpd.ts @@ -23,7 +23,9 @@ export const verificarConsentimento = query({ v.object({ aceito: v.boolean(), versao: v.string(), - aceitoEm: v.number() + aceitoEm: v.number(), + termoObrigatorio: v.boolean(), + versaoTermoAtual: v.string() }), v.null() ), @@ -35,6 +37,15 @@ export const verificarConsentimento = query({ const tipo = args.tipo || 'termo_uso'; + // Buscar configuração para verificar se termo é obrigatório + const config = await ctx.db + .query('configuracaoLGPD') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + const termoObrigatorio = config?.termoObrigatorio ?? false; + const versaoTermoAtual = config?.versaoTermoAtual ?? '1.0'; + const consentimento = await ctx.db .query('consentimentos') .withIndex('by_usuario_tipo', (q) => q.eq('usuarioId', usuario._id).eq('tipo', tipo)) @@ -42,13 +53,21 @@ export const verificarConsentimento = query({ .first(); if (!consentimento || !consentimento.aceito || consentimento.revogadoEm) { - return null; + return { + aceito: false, + versao: '', + aceitoEm: 0, + termoObrigatorio, + versaoTermoAtual + }; } return { aceito: consentimento.aceito, versao: consentimento.versao, - aceitoEm: consentimento.aceitoEm + aceitoEm: consentimento.aceitoEm, + termoObrigatorio, + versaoTermoAtual }; } }); @@ -476,15 +495,43 @@ export const exportarDadosUsuario = query({ } // Buscar todos os dados do usuário - const dadosUsuario: any = { + type DadosUsuario = { + usuario: { + nome: string; + email: string; + setor?: string; + }; + consentimentos: Array<{ + tipo: string; + aceito: boolean; + versao: string; + aceitoEm: number; + revogadoEm?: number; + }>; + solicitacoes: Array<{ + tipo: string; + status: string; + criadoEm: number; + respondidoEm?: number; + }>; + funcionario?: { + nome: string; + matricula?: string; + cpf: string; + email: string; + telefone: string; + descricaoCargo?: string; + }; + }; + + const dadosUsuario: DadosUsuario = { usuario: { nome: usuario.nome, email: usuario.email, setor: usuario.setor }, consentimentos: [], - solicitacoes: [], - atividades: [] + solicitacoes: [] }; // Consentimentos @@ -522,8 +569,7 @@ export const exportarDadosUsuario = query({ cpf: funcionario.cpf, email: funcionario.email, telefone: funcionario.telefone, - cargo: funcionario.cargo, - setor: funcionario.setor + descricaoCargo: funcionario.descricaoCargo }; } } @@ -655,8 +701,11 @@ export const obterConfiguracaoLGPD = query({ encarregadoNome: v.union(v.string(), v.null()), encarregadoEmail: v.union(v.string(), v.null()), encarregadoTelefone: v.union(v.string(), v.null()), + encarregadoHorarioAtendimento: v.union(v.string(), v.null()), prazoRespostaPadrao: v.number(), - diasAlertaVencimento: v.number() + diasAlertaVencimento: v.number(), + termoObrigatorio: v.boolean(), + versaoTermoAtual: v.string() }), v.null() ), @@ -672,8 +721,11 @@ export const obterConfiguracaoLGPD = query({ encarregadoNome: null, encarregadoEmail: null, encarregadoTelefone: null, + encarregadoHorarioAtendimento: null, prazoRespostaPadrao: 15, - diasAlertaVencimento: 3 + diasAlertaVencimento: 3, + termoObrigatorio: false, + versaoTermoAtual: '1.0' }; } @@ -681,8 +733,11 @@ export const obterConfiguracaoLGPD = query({ encarregadoNome: config.encarregadoNome ?? null, encarregadoEmail: config.encarregadoEmail ?? null, encarregadoTelefone: config.encarregadoTelefone ?? null, + encarregadoHorarioAtendimento: config.encarregadoHorarioAtendimento ?? null, prazoRespostaPadrao: config.prazoRespostaPadrao, - diasAlertaVencimento: config.diasAlertaVencimento + diasAlertaVencimento: config.diasAlertaVencimento, + termoObrigatorio: config.termoObrigatorio, + versaoTermoAtual: config.versaoTermoAtual }; } }); @@ -695,8 +750,11 @@ export const atualizarConfiguracaoLGPD = mutation({ encarregadoNome: v.optional(v.string()), encarregadoEmail: v.optional(v.string()), encarregadoTelefone: v.optional(v.string()), + encarregadoHorarioAtendimento: v.optional(v.string()), prazoRespostaPadrao: v.optional(v.number()), - diasAlertaVencimento: v.optional(v.number()) + diasAlertaVencimento: v.optional(v.number()), + termoObrigatorio: v.optional(v.boolean()), + versaoTermoAtual: v.optional(v.string()) }, returns: v.object({ sucesso: v.boolean() }), handler: async (ctx, args) => { @@ -716,13 +774,29 @@ export const atualizarConfiguracaoLGPD = mutation({ await ctx.db.patch(config._id, { ativo: false }); } + // Buscar valores atuais para manter os que não foram atualizados + const valoresAtuais = config || { + encarregadoNome: undefined, + encarregadoEmail: undefined, + encarregadoTelefone: undefined, + encarregadoHorarioAtendimento: undefined, + prazoRespostaPadrao: 15, + diasAlertaVencimento: 3, + termoObrigatorio: false, + versaoTermoAtual: '1.0' + }; + // Criar nova configuração await ctx.db.insert('configuracaoLGPD', { - encarregadoNome: args.encarregadoNome, - encarregadoEmail: args.encarregadoEmail, - encarregadoTelefone: args.encarregadoTelefone, - prazoRespostaPadrao: args.prazoRespostaPadrao ?? 15, - diasAlertaVencimento: args.diasAlertaVencimento ?? 3, + encarregadoNome: args.encarregadoNome ?? valoresAtuais.encarregadoNome ?? undefined, + encarregadoEmail: args.encarregadoEmail ?? valoresAtuais.encarregadoEmail ?? undefined, + encarregadoTelefone: args.encarregadoTelefone ?? valoresAtuais.encarregadoTelefone ?? undefined, + encarregadoHorarioAtendimento: + args.encarregadoHorarioAtendimento ?? valoresAtuais.encarregadoHorarioAtendimento ?? undefined, + prazoRespostaPadrao: args.prazoRespostaPadrao ?? valoresAtuais.prazoRespostaPadrao, + diasAlertaVencimento: args.diasAlertaVencimento ?? valoresAtuais.diasAlertaVencimento, + termoObrigatorio: args.termoObrigatorio ?? valoresAtuais.termoObrigatorio ?? false, + versaoTermoAtual: args.versaoTermoAtual ?? valoresAtuais.versaoTermoAtual ?? '1.0', ativo: true, atualizadoPor: usuario._id, atualizadoEm: Date.now() diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 7cf8095..796db0a 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1956,8 +1956,11 @@ export default defineSchema({ encarregadoNome: v.optional(v.string()), encarregadoEmail: v.optional(v.string()), encarregadoTelefone: v.optional(v.string()), + encarregadoHorarioAtendimento: v.optional(v.string()), // Ex: "Segunda a Sexta, das 8h às 17h" prazoRespostaPadrao: v.number(), // em dias (padrão: 15) diasAlertaVencimento: v.number(), // dias antes do prazo para alertar (padrão: 3) + termoObrigatorio: v.boolean(), // Se o termo de consentimento é obrigatório + versaoTermoAtual: v.string(), // Versão atual do termo (ex: "1.0") politicaRetencao: v.optional(v.string()), // JSON com política de retenção por tipo de dado ativo: v.boolean(), atualizadoPor: v.id("usuarios"), -- 2.49.1 From e460b114ed9e170fe96633ed63b662527ce2c459 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 2 Dec 2025 06:17:23 -0300 Subject: [PATCH 037/138] feat: implement user consent verification and redirection for LGPD compliance in dashboard layout and consent term page --- .../web/src/routes/(dashboard)/+layout.svelte | 39 +++++++++++++++++++ .../termo-consentimento/+page.svelte | 20 ++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/+layout.svelte b/apps/web/src/routes/(dashboard)/+layout.svelte index c8b1523..cf13b54 100644 --- a/apps/web/src/routes/(dashboard)/+layout.svelte +++ b/apps/web/src/routes/(dashboard)/+layout.svelte @@ -1,10 +1,49 @@ - -{#if verificando} -
    -
    - -

    Verificando permissões...

    -
    -
    -{:else if permitido} - {@render children?.()} -{:else} -
    -
    -
    - -
    -

    Acesso Negado

    -

    - Você não tem permissão para acessar esta ação. -

    -
    -
    -{/if} + + +{#if verificando} +
    +
    + +

    Verificando permissões...

    +
    +
    +{:else if permitido} + {@render children?.()} +{:else} +
    +
    +
    + +
    +

    Acesso Negado

    +

    Você não tem permissão para acessar esta ação.

    +
    +
    +{/if} diff --git a/apps/web/src/lib/components/AlterarStatusFerias.svelte b/apps/web/src/lib/components/AlterarStatusFerias.svelte index 2eff5bb..54f979f 100644 --- a/apps/web/src/lib/components/AlterarStatusFerias.svelte +++ b/apps/web/src/lib/components/AlterarStatusFerias.svelte @@ -172,7 +172,8 @@

    Cancelar Férias

    - Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não poderá mais ser processada. + Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não + poderá mais ser processada.
    @@ -208,7 +209,7 @@ xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" - class="stroke-current h-6 w-6 shrink-0" + class="h-6 w-6 shrink-0 stroke-current" > -
    +
    -

    -
    +

    +
    -
    -

    +

    +

    Nome

    @@ -173,14 +173,14 @@ nome={solicitacao.funcionario?.nome || 'N/A'} size="md" /> -

    +

    {solicitacao.funcionario?.nome || 'N/A'}

    {#if solicitacao.time} -
    -

    +

    +

    Time

    -

    -
    +

    +
    Data Início
    -
    +
    {new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
    Data Fim
    -
    +
    {new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
    Total de Dias
    -
    +
    {totalDias}
    dias corridos
    @@ -251,8 +251,8 @@
    -

    -
    +

    +
    Motivo da Ausência

    -
    +
    -

    +

    {solicitacao.motivo}

    @@ -280,9 +280,9 @@
    -
    +
    - Status:
    @@ -370,14 +370,14 @@ {#if motivoReprovacao !== undefined} -
    +
    diff --git a/apps/web/src/lib/components/AprovarFerias.svelte b/apps/web/src/lib/components/AprovarFerias.svelte index c68aabc..69b5cd0 100644 --- a/apps/web/src/lib/components/AprovarFerias.svelte +++ b/apps/web/src/lib/components/AprovarFerias.svelte @@ -70,10 +70,12 @@ const validacao = await client.query(api.saldoFerias.validarSolicitacao, { funcionarioId: periodo.funcionario._id, anoReferencia: periodo.anoReferencia, - periodos: [{ - dataInicio: periodo.dataInicio, - dataFim: periodo.dataFim - }] + periodos: [ + { + dataInicio: periodo.dataInicio, + dataFim: periodo.dataFim + } + ] }); if (!validacao.valido) { @@ -141,10 +143,12 @@ const validacao = await client.query(api.saldoFerias.validarSolicitacao, { funcionarioId: periodo.funcionario._id, anoReferencia: periodo.anoReferencia, - periodos: [{ - dataInicio: novaDataInicio, - dataFim: novaDataFim - }], + periodos: [ + { + dataInicio: novaDataInicio, + dataFim: novaDataFim + } + ], feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo }); @@ -243,15 +247,11 @@
    Início: - {formatarDataString(periodo.dataInicio)} + {formatarDataString(periodo.dataInicio)}
    Fim: - {formatarDataString(periodo.dataFim)} + {formatarDataString(periodo.dataFim)}
    Dias: diff --git a/apps/web/src/lib/components/CalendarioAfastamentos.svelte b/apps/web/src/lib/components/CalendarioAfastamentos.svelte index b419a67..2c2fbe6 100644 --- a/apps/web/src/lib/components/CalendarioAfastamentos.svelte +++ b/apps/web/src/lib/components/CalendarioAfastamentos.svelte @@ -1,540 +1,528 @@
    -
    - -
    -

    Calendário de Afastamentos

    +
    + +
    +

    Calendário de Afastamentos

    - -
    - Filtrar: -
    - - - - - - -
    -
    -
    + +
    + Filtrar: +
    + + + + + + +
    +
    +
    - -
    -
    -
    - Atestado Médico -
    -
    -
    - Declaração -
    -
    -
    - Licença Maternidade -
    -
    -
    - Licença Paternidade -
    -
    -
    - Férias -
    -
    + +
    +
    +
    + Atestado Médico +
    +
    +
    + Declaração +
    +
    +
    + Licença Maternidade +
    +
    +
    + Licença Paternidade +
    +
    +
    + Férias +
    +
    - -
    -
    -
    + +
    +
    +
    - - {#if showModal && eventoSelecionado} - - -
    (showModal = false)} - role="dialog" - aria-modal="true" - > - -
    e.stopPropagation()} - > - -
    -
    -
    -

    - {eventoSelecionado.funcionarioNome} -

    -

    - {getTipoNome(eventoSelecionado.tipo)} -

    -
    - -
    -
    + + {#if showModal && eventoSelecionado} + + +
    (showModal = false)} + role="dialog" + aria-modal="true" + > + +
    e.stopPropagation()} + > + +
    +
    +
    +

    + {eventoSelecionado.funcionarioNome} +

    +

    + {getTipoNome(eventoSelecionado.tipo)} +

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

    Data Início

    -

    - {formatarData(eventoSelecionado.start)} -

    -
    -
    + +
    +
    + + + +
    +

    Data Início

    +

    + {formatarData(eventoSelecionado.start)} +

    +
    +
    -
    - - - -
    -

    Data Fim

    -

    - {formatarData(eventoSelecionado.end)} -

    -
    -
    +
    + + + +
    +

    Data Fim

    +

    + {formatarData(eventoSelecionado.end)} +

    +
    +
    -
    - - - -
    -

    Duração

    -

    - {(() => { - const inicio = new Date(eventoSelecionado.start); - const fim = new Date(eventoSelecionado.end); - const diffTime = Math.abs(fim.getTime() - inicio.getTime()); - const diffDays = - Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; - return `${diffDays} ${diffDays === 1 ? "dia" : "dias"}`; - })()} -

    -
    -
    -
    +
    + + + +
    +

    Duração

    +

    + {(() => { + const inicio = new Date(eventoSelecionado.start); + const fim = new Date(eventoSelecionado.end); + const diffTime = Math.abs(fim.getTime() - inicio.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + return `${diffDays} ${diffDays === 1 ? 'dia' : 'dias'}`; + })()} +

    +
    +
    +
    - -
    - -
    -
    -
    - {/if} -
    + +
    + +
    +
    +
    + {/if} +
    diff --git a/apps/web/src/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte index c1cb5a0..a9feb63 100644 --- a/apps/web/src/lib/components/ErrorModal.svelte +++ b/apps/web/src/lib/components/ErrorModal.svelte @@ -10,34 +10,34 @@ } let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props(); - + let modalPosition = $state<{ top: number; left: number } | null>(null); // Função para calcular a posição baseada no card de registro de ponto function calcularPosicaoModal() { // Procurar pelo elemento do card de registro de ponto const cardRef = document.getElementById('card-registro-ponto-ref'); - + if (cardRef) { const rect = cardRef.getBoundingClientRect(); const viewportHeight = window.innerHeight; - + // Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto" const top = rect.top; - + // Garantir que o modal não saia da viewport // Considerar uma altura mínima do modal (aproximadamente 300px) const minTop = 20; const maxTop = viewportHeight - 350; // Deixar espaço para o modal const finalTop = Math.max(minTop, Math.min(top, maxTop)); - + // Centralizar horizontalmente return { top: finalTop, left: window.innerWidth / 2 }; } - + // Se não encontrar, usar posição padrão (centro da tela) return null; } @@ -53,18 +53,18 @@ } }); }; - + // Aguardar um pouco mais para garantir que o DOM está atualizado setTimeout(updatePosition, 50); - + // Adicionar listener de scroll para atualizar posição const handleScroll = () => { updatePosition(); }; - + window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleScroll); - + return () => { window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleScroll); @@ -85,17 +85,19 @@ // Se não houver posição calculada, centralizar na tela return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;'; } - + // Verificar se details contém instruções ou apenas detalhes técnicos const temInstrucoes = $derived.by(() => { if (!details) return false; // Se contém palavras-chave de instruções, é uma instrução - return details.includes('Por favor') || - details.includes('aguarde') || - details.includes('recarregue') || - details.includes('Verifique') || - details.includes('tente novamente') || - details.match(/^\d+\./); // Começa com número (lista numerada) + return ( + details.includes('Por favor') || + details.includes('aguarde') || + details.includes('recarregue') || + details.includes('Verifique') || + details.includes('tente novamente') || + details.match(/^\d+\./) + ); // Começa com número (lista numerada) }); function handleClose() { @@ -105,27 +107,29 @@ {#if open} - diff --git a/apps/web/src/lib/components/chat/PresenceManager.svelte b/apps/web/src/lib/components/chat/PresenceManager.svelte index 7c84e6b..0227d5d 100644 --- a/apps/web/src/lib/components/chat/PresenceManager.svelte +++ b/apps/web/src/lib/components/chat/PresenceManager.svelte @@ -1,114 +1,119 @@ diff --git a/apps/web/src/lib/components/chat/UserStatusBadge.svelte b/apps/web/src/lib/components/chat/UserStatusBadge.svelte index 2e1999e..0a1e86a 100644 --- a/apps/web/src/lib/components/chat/UserStatusBadge.svelte +++ b/apps/web/src/lib/components/chat/UserStatusBadge.svelte @@ -1,75 +1,74 @@
    - {@html config.icon} + {@html config.icon}
    - diff --git a/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte b/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte index c748d87..38e2762 100644 --- a/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte +++ b/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte @@ -127,7 +127,9 @@ // Verificar se o total não excede 30 dias const novoTotal = totalDiasSelecionados + dias; if (novoTotal > 30) { - toast.error(`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`); + toast.error( + `O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.` + ); return; } } @@ -135,7 +137,9 @@ // Verificar se o total não excede o saldo disponível const novoTotal = totalDiasSelecionados + dias; if (saldo && novoTotal > saldo.diasDisponiveis) { - toast.error(`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`); + toast.error( + `Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})` + ); return; } @@ -149,7 +153,7 @@ ]; toast.success(`Período de ${dias} dias adicionado! ✅`); - + // Limpar campos dataInicioPeriodo = ''; dataFimPeriodo = ''; @@ -263,14 +267,17 @@
    -

    +

    {labels[i]}

    {#if i < totalPassos - 1}
    i + 1} class:bg-base-300={passoAtual <= i + 1} @@ -303,7 +310,9 @@ style:border-width={anoSelecionado === ano ? '2px' : undefined} style:color={anoSelecionado === ano ? '#000000' : undefined} style:background-color={anoSelecionado === ano ? 'transparent' : undefined} - style:box-shadow={anoSelecionado === ano ? '0 0 10px rgba(249, 115, 22, 0.3)' : undefined} + style:box-shadow={anoSelecionado === ano + ? '0 0 10px rgba(249, 115, 22, 0.3)' + : undefined} onclick={() => (anoSelecionado = ano)} > {ano} @@ -413,7 +422,8 @@

    {#if ehEstatutarioPEOuMunicipal}

    - ⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias. + ⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode + exceder 30 dias.

    {/if}
    @@ -494,27 +504,24 @@

    {#if ehEstatutarioPEOuMunicipal}

    - ⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias. + ⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 + dias.

    {/if}
    -
    +

    Adicionar Período

    - +
    - +
    @@ -534,7 +541,7 @@ Dias
    - {diasPeriodoAtual} + {diasPeriodoAtual} dias
    @@ -569,7 +576,7 @@ {#if periodosFerias.length > 0} -
    +

    Períodos Adicionados ({periodosFerias.length})

    diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index 721b06b..a775954 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -25,27 +25,27 @@ function calcularPosicaoModal() { // Procurar pelo elemento do card de registro de ponto const cardRef = document.getElementById('card-registro-ponto-ref'); - + if (cardRef) { const rect = cardRef.getBoundingClientRect(); const viewportHeight = window.innerHeight; - + // Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto" const top = rect.top; - + // Garantir que o modal não saia da viewport // Considerar uma altura mínima do modal (aproximadamente 300px) const minTop = 20; const maxTop = viewportHeight - 350; // Deixar espaço para o modal const finalTop = Math.max(minTop, Math.min(top, maxTop)); - + // Centralizar horizontalmente return { top: finalTop, left: window.innerWidth / 2 }; } - + // Se não encontrar, usar posição padrão (centro da tela) return null; } @@ -53,37 +53,37 @@ // Atualizar posição quando o modal for aberto (quando registroQuery tiver dados) $effect(() => { if (registroQuery?.data) { - // Usar requestAnimationFrame para garantir que o DOM está completamente renderizado - const updatePosition = () => { - requestAnimationFrame(() => { - const pos = calcularPosicaoModal(); - if (pos) { - modalPosition = pos; + // Usar requestAnimationFrame para garantir que o DOM está completamente renderizado + const updatePosition = () => { + requestAnimationFrame(() => { + const pos = calcularPosicaoModal(); + if (pos) { + modalPosition = pos; } else { // Fallback para centralização modalPosition = { top: window.innerHeight / 2, left: window.innerWidth / 2 }; - } - }); - }; - + } + }); + }; + // Aguardar um pouco para garantir que o DOM está atualizado - setTimeout(updatePosition, 50); - - // Adicionar listener de scroll para atualizar posição - const handleScroll = () => { - updatePosition(); - }; - - window.addEventListener('scroll', handleScroll, true); - window.addEventListener('resize', handleScroll); - - return () => { - window.removeEventListener('scroll', handleScroll, true); - window.removeEventListener('resize', handleScroll); - }; + setTimeout(updatePosition, 50); + + // Adicionar listener de scroll para atualizar posição + const handleScroll = () => { + updatePosition(); + }; + + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleScroll); + + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleScroll); + }; } else { // Limpar posição quando o modal for fechado modalPosition = null; @@ -137,7 +137,9 @@ 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.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' }); @@ -154,7 +156,7 @@ // Informações do Funcionário em tabela const funcionarioData: string[][] = []; - + if (registro.funcionario) { if (registro.funcionario.matricula) { funcionarioData.push(['Matrícula', registro.funcionario.matricula]); @@ -164,10 +166,14 @@ funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]); } if (registro.funcionario.simbolo) { - const simboloTipo = registro.funcionario.simbolo.tipo === 'cargo_comissionado' - ? 'Cargo Comissionado' - : 'Função Gratificada'; - funcionarioData.push(['Símbolo', `${registro.funcionario.simbolo.nome} (${simboloTipo})`]); + const simboloTipo = + registro.funcionario.simbolo.tipo === 'cargo_comissionado' + ? 'Cargo Comissionado' + : 'Função Gratificada'; + funcionarioData.push([ + 'Símbolo', + `${registro.funcionario.simbolo.nome} (${simboloTipo})` + ]); } } @@ -202,12 +208,17 @@ nomeEntrada: config.nomeEntrada, nomeSaidaAlmoco: config.nomeSaidaAlmoco, nomeRetornoAlmoco: config.nomeRetornoAlmoco, - nomeSaida: config.nomeSaida, + nomeSaida: config.nomeSaida }) : getTipoRegistroLabel(registro.tipo); - - const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo); - + + const dataHora = formatarDataHoraCompleta( + registro.data, + registro.hora, + registro.minuto, + registro.segundo + ); + const registroData: string[][] = [ ['Tipo', tipoLabel], ['Data e Hora', dataHora], @@ -260,10 +271,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 = () => { @@ -307,7 +318,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(); @@ -351,46 +362,53 @@ } -
    e.key === 'Escape' && onClose()} - role="dialog" - aria-modal="true" - aria-labelledby="modal-comprovante-title" - > + role="dialog" + aria-modal="true" + aria-labelledby="modal-comprovante-title" +> -
    - + -
    e.stopPropagation()} > -
    +
    -
    - +
    +
    - -

    Detalhes do registro realizado

    + +

    Detalhes do registro realizado

    -
    -

    - Acompanhe e gerencie os fluxos de trabalho. Visualize o progresso, - documentos e responsáveis de cada etapa. + Acompanhe e gerencie os fluxos de trabalho. Visualize o progresso, documentos e + responsáveis de cada etapa.

    - @@ -187,7 +195,9 @@

    Nenhum fluxo encontrado

    - {statusFilter ? 'Não há fluxos com este status.' : 'Clique em "Novo Fluxo" para iniciar um fluxo.'} + {statusFilter + ? 'Não há fluxos com este status.' + : 'Clique em "Novo Fluxo" para iniciar um fluxo.'}

    {:else} @@ -207,7 +217,10 @@ {#each instancesQuery.data as instance (instance._id)} {@const statusBadge = getStatusBadge(instance.status)} - {@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)} + {@const progressPercent = getProgressPercentage( + instance.progress.completed, + instance.progress.total + )}
    {instance.templateName ?? 'Template desconhecido'}
    @@ -222,12 +235,9 @@ {instance.managerName ?? '-'}
    - - + {instance.progress.completed}/{instance.progress.total}
    @@ -237,13 +247,27 @@ {formatDate(instance.startedAt)} - -
    {/if} -
    { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4"> + { + e.preventDefault(); + handleCreate(); + }} + class="mt-4 space-y-4" + >
    @@ -323,7 +355,9 @@ {/if}

    - Opcional: vincule este fluxo a um contrato específico + Opcional: vincule este fluxo a um contrato específico

    @@ -359,7 +393,11 @@
    - +
    {/if} - diff --git a/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte index f8fd33e..9d89d51 100644 --- a/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte @@ -16,17 +16,16 @@ // Estado para query de usuários por setor let setorIdParaAtribuicao = $state | null>(null); - + // Query de usuários por setor para atribuição (reativa baseada no setorId) - const usuariosPorSetorQuery = useQuery( - api.flows.getUsuariosBySetorForAssignment, - () => setorIdParaAtribuicao ? { setorId: setorIdParaAtribuicao } : 'skip' + const usuariosPorSetorQuery = useQuery(api.flows.getUsuariosBySetorForAssignment, () => + setorIdParaAtribuicao ? { setorId: setorIdParaAtribuicao } : 'skip' ); // Estado de operações let isProcessing = $state(false); let processingError = $state(null); - + // Estado de toast let toastMessage = $state(null); let toastType = $state<'success' | 'error' | 'warning' | 'info'>('error'); @@ -38,7 +37,11 @@ // Modal de notas let showNotesModal = $state(false); - let stepForNotes = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string; notes: string } | null>(null); + let stepForNotes = $state<{ + _id: Id<'flowInstanceSteps'>; + stepName: string; + notes: string; + } | null>(null); let editedNotes = $state(''); // Modal de upload @@ -114,7 +117,12 @@ } } - function openReassignModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string; assignedToId?: Id<'usuarios'>; setorId: Id<'setores'> }) { + function openReassignModal(step: { + _id: Id<'flowInstanceSteps'>; + stepName: string; + assignedToId?: Id<'usuarios'>; + setorId: Id<'setores'>; + }) { stepToReassign = step; newAssigneeId = step.assignedToId ?? ''; setorIdParaAtribuicao = step.setorId; @@ -161,7 +169,11 @@ } } - function openNotesModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string; notes?: string }) { + function openNotesModal(step: { + _id: Id<'flowInstanceSteps'>; + stepName: string; + notes?: string; + }) { stepForNotes = { ...step, notes: step.notes ?? '' }; editedNotes = step.notes ?? ''; showNotesModal = true; @@ -366,10 +378,14 @@ // Funções de sub-etapas function getFileIcon(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase(); - if (['pdf'].includes(ext || '')) return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; - if (['doc', 'docx'].includes(ext || '')) return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; - if (['xls', 'xlsx'].includes(ext || '')) return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; - if (['jpg', 'jpeg', 'png', 'gif'].includes(ext || '')) return 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'; + if (['pdf'].includes(ext || '')) + return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; + if (['doc', 'docx'].includes(ext || '')) + return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; + if (['xls', 'xlsx'].includes(ext || '')) + return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; + if (['jpg', 'jpeg', 'png', 'gif'].includes(ext || '')) + return 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'; return 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; } @@ -443,7 +459,10 @@ } } - async function handleAtualizarStatusSubEtapa(subEtapaId: Id<'flowSubSteps'>, novoStatus: 'pending' | 'in_progress' | 'completed' | 'blocked') { + async function handleAtualizarStatusSubEtapa( + subEtapaId: Id<'flowSubSteps'>, + novoStatus: 'pending' | 'in_progress' | 'completed' | 'blocked' + ) { try { await client.mutation(api.flows.atualizarSubEtapa, { subEtapaId, @@ -466,11 +485,25 @@
    {:else if !instanceQuery.data} {:else} {@const instance = instanceQuery.data.instance} @@ -482,9 +515,11 @@ class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl" >
    -
    +
    -
    +
    @@ -517,9 +564,21 @@ {instance.contratoId}
    {/if} -
    -
    +
    Gerente: {instance.managerName ?? '-'} @@ -529,24 +588,54 @@ onclick={openAlterarGestorModal} aria-label="Alterar gestor" > -
    -
    -
    + Iniciado: {formatDate(instance.startedAt)}
    @@ -555,9 +644,21 @@ {#if instance.status === 'active'} - +
    {/if} @@ -587,26 +700,66 @@ {@const stepStatus = getStatusBadge(step.status)} {@const isCurrent = isStepCurrent(step._id)} {@const overdue = step.status !== 'completed' && isOverdue(step.dueDate)} - {@const subEtapasQuery = useQuery(api.flows.listarSubEtapas, () => ({ flowInstanceStepId: step._id }))} + {@const subEtapasQuery = useQuery(api.flows.listarSubEtapas, () => ({ + flowInstanceStepId: step._id + }))} {@const subEtapas = subEtapasQuery.data} {@const subEtapasCount = subEtapas?.length ?? 0} - {@const subEtapasCompleted = subEtapas?.filter((s: { status: string }) => s.status === 'completed').length ?? 0} + {@const subEtapasCompleted = + subEtapas?.filter((s: { status: string }) => s.status === 'completed').length ?? 0}
    {#if index < steps.length - 1} -
    +
    {/if} -
    +
    {#if step.status === 'completed'} - {index + 1} @@ -614,11 +767,15 @@
    -
    +
    -
    -
    +
    +

    {step.stepName}

    {stepStatus.label} - {#if step.assignedToName} - {/if} {#if step.dueDate} - - + Prazo: {formatDate(step.dueDate)} @@ -707,8 +904,20 @@ aria-label="Reatribuir responsável" title="Reatribuir responsável" > -
    -
    -
    +
    +
    -

    Sub-etapas

    +

    Sub-etapas

    {#if subEtapasCount > 0} {subEtapasCompleted} / {subEtapasCount} concluídas @@ -737,14 +958,26 @@ onclick={() => openSubEtapaModal(step._id)} aria-label="Adicionar sub-etapa" > -
    - + {#if subEtapasQuery.isLoading}
    @@ -752,24 +985,50 @@ {:else if subEtapas && subEtapas.length > 0}
    {#each subEtapas as subEtapa (subEtapa._id)} -
    -
    -
    -
    {subEtapa.name}
    - - {subEtapa.status === 'completed' ? 'Concluída' : subEtapa.status === 'in_progress' ? 'Em Andamento' : subEtapa.status === 'blocked' ? 'Bloqueada' : 'Pendente'} +
    +
    +
    +
    {subEtapa.name}
    + + {subEtapa.status === 'completed' + ? 'Concluída' + : subEtapa.status === 'in_progress' + ? 'Em Andamento' + : subEtapa.status === 'blocked' + ? 'Bloqueada' + : 'Pendente'}
    {#if subEtapa.description} -
    {subEtapa.description}
    +
    + {subEtapa.description} +
    {/if}
    -
    +
    {#if instance.status === 'active'} {:else if usuariosPorSetorQuery.data && usuariosPorSetorQuery.data.length === 0}
    - Não há funcionários cadastrados neste setor
    @@ -1125,10 +1591,12 @@
    - +
    {/if} @@ -1163,9 +1636,7 @@
    - +
    {/if} @@ -1206,10 +1678,12 @@ {/if}
    - +
    {/if} @@ -1225,16 +1704,14 @@ {#if showCancelModal} {/if} - diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index 92059d1..b994967 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -129,14 +129,13 @@ const gestorIdDisponivel = $derived(currentUser?.data?._id ?? null); // Verificar autenticação antes de executar queries - const usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined); + const usuarioAutenticado = $derived( + currentUser?.data !== null && currentUser?.data !== undefined + ); // ✅ CORRIGIDO: Queries condicionais - só executar se usuário estiver autenticado // Queries que não requerem argumentos são criadas uma vez - const funcionarioQuery = useQuery( - api.funcionarios.getCurrent, - usuarioAutenticado ? {} : 'skip' - ); + const funcionarioQuery = useQuery(api.funcionarios.getCurrent, usuarioAutenticado ? {} : 'skip'); const timesSubordinadosQuery = useQuery( api.times.listarSubordinadosDoGestorAtual, usuarioAutenticado ? {} : 'skip' @@ -604,8 +603,9 @@ // Garantir que o tema continue aplicado após salvar aplicarTema(temaSelecionado); - sucessoSalvarTema = 'Tema salvo com sucesso! Sua preferência será aplicada em acessos futuros.'; - + sucessoSalvarTema = + 'Tema salvo com sucesso! Sua preferência será aplicada em acessos futuros.'; + // Limpar mensagem após 3 segundos setTimeout(() => { sucessoSalvarTema = null; @@ -2216,18 +2216,18 @@ - -
    - -
    - {periodo.funcionario?.nome} + +
    + +
    + {periodo.funcionario?.nome} +
    -
    - + {#if periodo.time}
    - -
    - -
    - {ausencia.funcionario?.nome || 'N/A'} + +
    + +
    + {ausencia.funcionario?.nome || 'N/A'} +
    -
    - + {#if ausencia.time}
    selecionarTema(tema.id)} @@ -2516,9 +2516,7 @@ style="background: linear-gradient(135deg, {tema.corPrimaria} 0%, {tema.corSecundaria} 100%);" >
    -
    +
    @@ -2566,9 +2564,9 @@

    Como funciona?

    - Clique em um tema para visualizar a prévia. O tema será aplicado - imediatamente, mas você precisa clicar em "Salvar Tema" para que a - preferência seja mantida em acessos futuros. + Clique em um tema para visualizar a prévia. O tema será aplicado imediatamente, mas + você precisa clicar em "Salvar Tema" para que a preferência seja mantida em acessos + futuros.

    diff --git a/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte index 6c06919..21c0124 100644 --- a/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte @@ -1,326 +1,323 @@
    -
    -
    -
    -

    Meu Perfil

    -

    Meus Chamados

    -

    - Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em tempo real. -

    -
    -
    - Abrir novo chamado - -
    -
    -
    +
    +
    +
    +

    Meu Perfil

    +

    Meus Chamados

    +

    + Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em tempo + real. +

    +
    +
    + Abrir novo chamado + +
    +
    +
    -
    - -
    - {#if !selectedTicketId || !detalheAtual} -
    - {#if carregandoDetalhe} - - {:else} -

    Selecione um chamado para visualizar os detalhes.

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

    Ticket {detalheAtual.ticket.numero}

    -

    {detalheAtual.ticket.titulo}

    -

    {detalheAtual.ticket.descricao}

    -
    - - {getStatusLabel(detalheAtual.ticket.status)} - -
    +
    + {#if !selectedTicketId || !detalheAtual} +
    + {#if carregandoDetalhe} + + {:else} +

    Selecione um chamado para visualizar os detalhes.

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

    + Ticket {detalheAtual.ticket.numero} +

    +

    {detalheAtual.ticket.titulo}

    +

    {detalheAtual.ticket.descricao}

    +
    + + {getStatusLabel(detalheAtual.ticket.status)} + +
    -
    - - Tipo: {detalheAtual.ticket.tipo.charAt(0).toUpperCase() + detalheAtual.ticket.tipo.slice(1)} - - - Prioridade: {detalheAtual.ticket.prioridade} - - - Última interação: {formatarData(detalheAtual.ticket.ultimaInteracaoEm)} - -
    +
    + + Tipo: {detalheAtual.ticket.tipo.charAt(0).toUpperCase() + + detalheAtual.ticket.tipo.slice(1)} + + + Prioridade: {detalheAtual.ticket.prioridade} + + + Última interação: {formatarData(detalheAtual.ticket.ultimaInteracaoEm)} + +
    - {#if statusAlertas(detalheAtual.ticket).length > 0} -
    - {#each statusAlertas(detalheAtual.ticket) as alerta (alerta.label)} -
    - {alerta.label} -
    - {/each} -
    - {/if} + {#if statusAlertas(detalheAtual.ticket).length > 0} +
    + {#each statusAlertas(detalheAtual.ticket) as alerta (alerta.label)} +
    + {alerta.label} +
    + {/each} +
    + {/if} -
    -
    -

    Timeline e SLA

    -

    - Etapas monitoradas com indicadores de prazo. -

    -
    - -
    -
    +
    +
    +

    Timeline e SLA

    +

    Etapas monitoradas com indicadores de prazo.

    +
    + +
    +
    -
    -

    Responsabilidade

    -

    - {detalheAtual.ticket.responsavelId - ? `Responsável: ${detalheAtual.ticket.setorResponsavel ?? "Equipe TI"}` - : "Aguardando atribuição"} -

    -
    -

    Prazo resposta: {prazoRestante(detalheAtual.ticket.prazoResposta) ?? "--"}

    -

    Prazo conclusão: {prazoRestante(detalheAtual.ticket.prazoConclusao) ?? "--"}

    -

    Prazo encerramento: {prazoRestante(detalheAtual.ticket.prazoEncerramento) ?? "--"}

    -
    -

    - {getStatusDescription(detalheAtual.ticket.status)} -

    -
    -
    +
    +

    Responsabilidade

    +

    + {detalheAtual.ticket.responsavelId + ? `Responsável: ${detalheAtual.ticket.setorResponsavel ?? 'Equipe TI'}` + : 'Aguardando atribuição'} +

    +
    +

    Prazo resposta: {prazoRestante(detalheAtual.ticket.prazoResposta) ?? '--'}

    +

    Prazo conclusão: {prazoRestante(detalheAtual.ticket.prazoConclusao) ?? '--'}

    +

    + Prazo encerramento: {prazoRestante(detalheAtual.ticket.prazoEncerramento) ?? '--'} +

    +
    +

    + {getStatusDescription(detalheAtual.ticket.status)} +

    +
    +
    -
    -
    -

    Interações

    -
    - {#if detalheAtual.interactions.length === 0} -

    - Nenhuma interação registrada ainda. -

    - {:else} - {#each detalheAtual.interactions as interacao (interacao._id)} -
    -
    - {interacao.origem === "usuario" ? "Você" : interacao.origem} - {formatarData(interacao.criadoEm)} -
    -

    - {interacao.conteudo} -

    - {#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior} - - Status: {getStatusLabel(interacao.statusNovo)} - - {/if} -
    - {/each} - {/if} -
    -
    +
    +
    +

    Interações

    +
    + {#if detalheAtual.interactions.length === 0} +

    Nenhuma interação registrada ainda.

    + {:else} + {#each detalheAtual.interactions as interacao (interacao._id)} +
    +
    + {interacao.origem === 'usuario' ? 'Você' : interacao.origem} + {formatarData(interacao.criadoEm)} +
    +

    + {interacao.conteudo} +

    + {#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior} + + Status: {getStatusLabel(interacao.statusNovo)} + + {/if} +
    + {/each} + {/if} +
    +
    -
    -

    Enviar atualização

    - - {#if erroMensagem} -

    {erroMensagem}

    - {/if} - {#if sucessoMensagem} -

    {sucessoMensagem}

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

    Enviar atualização

    + + {#if erroMensagem} +

    {erroMensagem}

    + {/if} + {#if sucessoMensagem} +

    {sucessoMensagem}

    + {/if} + +
    +
    + {/if} + +
    - diff --git a/apps/web/src/routes/(dashboard)/perfil/privacidade/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/privacidade/+page.svelte index 2994ed5..6128474 100644 --- a/apps/web/src/routes/(dashboard)/perfil/privacidade/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/privacidade/+page.svelte @@ -22,7 +22,7 @@ return labels[tipo] || tipo; } - async function revogar(tipo: string) { + async function revogar(tipo: string) { if (!confirm('Tem certeza que deseja revogar este consentimento?')) { return; } @@ -30,7 +30,13 @@ revogando = tipo; try { - await client.mutation(api.lgpd.revogarConsentimento, { tipo: tipo as 'termo_uso' | 'politica_privacidade' | 'comunicacoes' | 'compartilhamento_dados' }); + await client.mutation(api.lgpd.revogarConsentimento, { + tipo: tipo as + | 'termo_uso' + | 'politica_privacidade' + | 'comunicacoes' + | 'compartilhamento_dados' + }); toast.success('Consentimento revogado com sucesso'); } catch (error: any) { toast.error(error.message || 'Erro ao revogar consentimento'); @@ -40,15 +46,15 @@ } -
    +
    -
    -
    - +
    +
    +
    -

    Preferências de Privacidade

    +

    Preferências de Privacidade

    Gerencie seus consentimentos e preferências de privacidade

    @@ -57,32 +63,32 @@
    -
    +
    -

    Meus Consentimentos

    +

    Meus Consentimentos

    {#if consentimentos === undefined} -
    +
    {:else if consentimentos.length === 0} -
    - +
    +

    Nenhum consentimento registrado

    {:else}
    {#each consentimentos as consentimento} -
    +
    -
    +
    {#if consentimento.aceito && !consentimento.revogadoEm} - + {:else} - + {/if} -

    +

    {getTipoLabel(consentimento.tipo)}

    {#if consentimento.aceito && !consentimento.revogadoEm} @@ -92,7 +98,7 @@ {/if}
    -
    +
    @@ -103,18 +109,17 @@
    - Versão: {consentimento.versao} + Versão: + {consentimento.versao}
    {#if consentimento.revogadoEm} -
    +
    Revogado em:{' '} - {format( - new Date(consentimento.revogadoEm), - 'dd/MM/yyyy às HH:mm', - { locale: ptBR } - )} + {format(new Date(consentimento.revogadoEm), 'dd/MM/yyyy às HH:mm', { + locale: ptBR + })}
    {/if} @@ -145,42 +150,42 @@
    -

    Informações Importantes

    +

    Informações Importantes

    Atenção ao Revogar Consentimentos

    - A revogação de alguns consentimentos pode impedir o acesso a funcionalidades do - sistema que dependem do tratamento de dados pessoais. + A revogação de alguns consentimentos pode impedir o acesso a funcionalidades do sistema + que dependem do tratamento de dados pessoais.

    -
    +

    - Termo de Uso: Aceite obrigatório para utilização do sistema. A - revogação pode impedir o acesso. + Termo de Uso: Aceite obrigatório para utilização do sistema. A revogação pode + impedir o acesso.

    - Política de Privacidade: Informa como seus dados são tratados. A - revogação não impede o tratamento, mas você pode solicitar exclusão. + Política de Privacidade: Informa como seus dados são tratados. A revogação + não impede o tratamento, mas você pode solicitar exclusão.

    - Comunicações: Permite envio de notificações e comunicações do - sistema. A revogação pode limitar informações importantes. + Comunicações: Permite envio de notificações e comunicações do sistema. A revogação + pode limitar informações importantes.

    - Compartilhamento de Dados: Permite compartilhamento com terceiros - quando necessário. A revogação pode afetar serviços terceirizados. + Compartilhamento de Dados: Permite compartilhamento com terceiros quando necessário. + A revogação pode afetar serviços terceirizados.

    - - diff --git a/apps/web/src/routes/(dashboard)/privacidade/+page.svelte b/apps/web/src/routes/(dashboard)/privacidade/+page.svelte index 4e06757..a905b4b 100644 --- a/apps/web/src/routes/(dashboard)/privacidade/+page.svelte +++ b/apps/web/src/routes/(dashboard)/privacidade/+page.svelte @@ -6,27 +6,33 @@ const configLGPD = useQuery(api.lgpd.obterConfiguracaoLGPD, {}); - const encarregadoNome = $derived(configLGPD?.data?.encarregadoNome || 'Encarregado de Proteção de Dados'); - const encarregadoEmail = $derived(configLGPD?.data?.encarregadoEmail || 'lgpd@esportes.pe.gov.br'); + const encarregadoNome = $derived( + configLGPD?.data?.encarregadoNome || 'Encarregado de Proteção de Dados' + ); + const encarregadoEmail = $derived( + configLGPD?.data?.encarregadoEmail || 'lgpd@esportes.pe.gov.br' + ); const encarregadoTelefone = $derived(configLGPD?.data?.encarregadoTelefone || '(81) 3184-XXXX'); - const encarregadoHorario = $derived(configLGPD?.data?.encarregadoHorarioAtendimento || 'Segunda a Sexta, das 8h às 17h'); + const encarregadoHorario = $derived( + configLGPD?.data?.encarregadoHorarioAtendimento || 'Segunda a Sexta, das 8h às 17h' + ); -
    +
    -
    -
    - +
    +
    +
    -

    Política de Privacidade

    +

    Política de Privacidade

    Lei Geral de Proteção de Dados Pessoais (LGPD) - Lei nº 13.709/2018

    -
    +
    Última atualização: {new Date().toLocaleDateString('pt-BR')}
    @@ -35,76 +41,76 @@
    -
    +
    -

    1. Introdução

    +

    1. Introdução

    A Secretaria de Esportes do Estado de Pernambuco, no exercício de suas atribuições constitucionais e legais, está comprometida com a proteção dos dados pessoais de seus - servidores, colaboradores e cidadãos, em conformidade com a Lei Geral de Proteção de - Dados Pessoais (LGPD) - Lei nº 13.709/2018. + servidores, colaboradores e cidadãos, em conformidade com a Lei Geral de Proteção de Dados + Pessoais (LGPD) - Lei nº 13.709/2018.

    - Esta Política de Privacidade descreve como coletamos, utilizamos, armazenamos e - protegemos seus dados pessoais no Sistema de Gestão da Secretaria de Esportes (SGSE). + Esta Política de Privacidade descreve como coletamos, utilizamos, armazenamos e protegemos + seus dados pessoais no Sistema de Gestão da Secretaria de Esportes (SGSE).

    -
    +
    -

    2. Dados Pessoais Coletados

    +

    2. Dados Pessoais Coletados

    O SGSE coleta e processa os seguintes tipos de dados pessoais:

    - +

    Dados de Identificação

    -

    - Nome completo, CPF, RG, data de nascimento, naturalidade, nacionalidade, - estado civil, filiação (nome do pai e mãe) +

    + Nome completo, CPF, RG, data de nascimento, naturalidade, nacionalidade, estado + civil, filiação (nome do pai e mãe)

    - +

    Dados de Contato

    -

    +

    E-mail, telefone, endereço residencial e endereços de marcação de ponto

    - +

    Dados Profissionais

    -

    - Matrícula, cargo, função, setor, data de admissão, regime de trabalho, - documentos profissionais (CTPS, título eleitor, reservista, PIS) +

    + Matrícula, cargo, função, setor, data de admissão, regime de trabalho, documentos + profissionais (CTPS, título eleitor, reservista, PIS)

    - +

    Dados de Saúde

    -

    - Atestados médicos, licenças de saúde, grupo sanguíneo, fator RH (quando - necessário para atividades específicas) +

    + Atestados médicos, licenças de saúde, grupo sanguíneo, fator RH (quando necessário + para atividades específicas)

    - +

    Dados de Acesso

    -

    - Credenciais de acesso, logs de acesso, endereço IP, histórico de atividades - no sistema +

    + Credenciais de acesso, logs de acesso, endereço IP, histórico de atividades no + sistema

    @@ -113,13 +119,13 @@
    -
    +
    -

    3. Finalidade do Tratamento

    +

    3. Finalidade do Tratamento

    Os dados pessoais são tratados para as seguintes finalidades:

    -
      +
      • Gestão de recursos humanos e folha de pagamento
      • Controle de ponto e registro de jornada de trabalho
      • Gestão de férias, ausências e licenças
      • @@ -135,39 +141,38 @@
    -
    +
    -

    4. Base Legal do Tratamento

    +

    4. Base Legal do Tratamento

    - O tratamento de dados pessoais no SGSE fundamenta-se nas seguintes bases legais, - conforme previsto no Art. 7º da LGPD: + O tratamento de dados pessoais no SGSE fundamenta-se nas seguintes bases legais, conforme + previsto no Art. 7º da LGPD:

    -
    -

    I. Execução de Políticas Públicas

    -

    - Art. 7º, II - Para a execução de políticas públicas previstas em leis ou - regulamentos +

    +

    I. Execução de Políticas Públicas

    +

    + Art. 7º, II - Para a execução de políticas públicas previstas em leis ou regulamentos

    -
    -

    II. Cumprimento de Obrigação Legal

    -

    +

    +

    II. Cumprimento de Obrigação Legal

    +

    Art. 7º, I - Para cumprimento de obrigação legal ou regulatória pelo controlador

    -
    -

    III. Execução de Contrato

    -

    - Art. 7º, V - Para a execução de contrato ou de procedimentos preliminares - relacionados a contrato do qual seja parte o titular +

    +

    III. Execução de Contrato

    +

    + Art. 7º, V - Para a execução de contrato ou de procedimentos preliminares relacionados + a contrato do qual seja parte o titular

    -
    -

    IV. Proteção da Vida e Saúde

    -

    - Art. 7º, VI e VII - Para a proteção da vida ou da incolumidade física do titular - ou de terceiro, e para a tutela da saúde +

    +

    IV. Proteção da Vida e Saúde

    +

    + Art. 7º, VI e VII - Para a proteção da vida ou da incolumidade física do titular ou de + terceiro, e para a tutela da saúde

    @@ -175,46 +180,43 @@
    -
    +
    -

    5. Compartilhamento de Dados

    -

    - Os dados pessoais podem ser compartilhados com: -

    -
      +

      5. Compartilhamento de Dados

      +

      Os dados pessoais podem ser compartilhados com:

      +
      • - Órgãos Públicos: Quando necessário para cumprimento de obrigações - legais ou execução de políticas públicas + Órgãos Públicos: Quando necessário para cumprimento de obrigações legais + ou execução de políticas públicas
      • - Fornecedores de Serviços: Empresas contratadas para prestação de - serviços técnicos, sempre com garantias de proteção de dados + Fornecedores de Serviços: Empresas contratadas para prestação de serviços + técnicos, sempre com garantias de proteção de dados
      • - Autoridades Competentes: Quando exigido por determinação judicial ou - legal + Autoridades Competentes: Quando exigido por determinação judicial ou legal

      - Todos os compartilhamentos são realizados com base legal e com garantias de - proteção dos dados pessoais. + Todos os compartilhamentos são realizados com base legal e com garantias de proteção dos + dados pessoais.

    -
    +
    -

    6. Medidas de Segurança

    +

    6. Medidas de Segurança

    Adotamos medidas técnicas e administrativas para proteger seus dados pessoais:

    -
    -
    -

    Medidas Técnicas

    -
      +
      +
      +

      Medidas Técnicas

      +
      • • Criptografia de dados sensíveis
      • • Controle de acesso por permissões
      • • Logs de auditoria
      • @@ -222,9 +224,9 @@
      • • Monitoramento de segurança
      -
      -

      Medidas Administrativas

      -
        +
        +

        Medidas Administrativas

        +
        • • Treinamento de equipe
        • • Políticas de acesso
        • • Procedimentos de segurança
        • @@ -237,20 +239,19 @@
    -
    +
    -

    7. Prazo de Retenção

    +

    7. Prazo de Retenção

    Os dados pessoais são mantidos pelo prazo necessário para:

    -
      +
      • - Dados de Funcionários Ativos: Durante todo o período de vínculo - empregatício/estatutário + Dados de Funcionários Ativos: Durante todo o período de vínculo empregatício/estatutário
      • - Dados de Funcionários Inativos: Conforme prazo legal aplicável (em - geral, 5 anos após desligamento) + Dados de Funcionários Inativos: Conforme prazo legal aplicável (em geral, + 5 anos após desligamento)
      • Logs de Acesso: 2 anos, conforme recomendação da ANPD @@ -267,9 +268,9 @@
    -
    +
    -

    8. Direitos do Titular dos Dados

    +

    8. Direitos do Titular dos Dados

    Conforme previsto no Art. 18 da LGPD, você possui os seguintes direitos:

    @@ -278,16 +279,14 @@
    1

    Confirmação da Existência de Tratamento

    -

    - Confirmar se tratamos seus dados pessoais -

    +

    Confirmar se tratamos seus dados pessoais

    2

    Acesso aos Dados

    -

    +

    Acessar seus dados pessoais tratados por nós

    @@ -296,7 +295,7 @@
    3

    Correção de Dados

    -

    +

    Solicitar correção de dados incompletos, inexatos ou desatualizados

    @@ -305,9 +304,8 @@
    4

    Anonimização, Bloqueio ou Eliminação

    -

    - Solicitar anonimização, bloqueio ou eliminação de dados desnecessários ou - excessivos +

    + Solicitar anonimização, bloqueio ou eliminação de dados desnecessários ou excessivos

    @@ -315,7 +313,7 @@
    5

    Portabilidade dos Dados

    -

    +

    Solicitar a portabilidade dos dados a outro fornecedor de serviço ou produto

    @@ -324,7 +322,7 @@
    6

    Eliminação de Dados

    -

    +

    Solicitar a eliminação dos dados pessoais tratados com base em consentimento

    @@ -333,7 +331,7 @@
    7

    Informação sobre Compartilhamento

    -

    +

    Obter informações sobre compartilhamento de dados com terceiros

    @@ -342,7 +340,7 @@
    8

    Revogação de Consentimento

    -

    +

    Revogar seu consentimento, quando aplicável

    @@ -361,17 +359,17 @@
    -
    +
    -

    9. Encarregado de Proteção de Dados (DPO)

    +

    9. Encarregado de Proteção de Dados (DPO)

    Para exercer seus direitos ou esclarecer dúvidas sobre o tratamento de dados pessoais, entre em contato com nosso Encarregado de Proteção de Dados:

    -
    +
    {#if encarregadoNome && encarregadoNome !== 'Encarregado de Proteção de Dados'}
    - +

    Nome

    {encarregadoNome}

    @@ -379,21 +377,21 @@
    {/if}
    - +

    E-mail

    {encarregadoEmail}

    - +

    Telefone

    {encarregadoTelefone}

    - +

    Horário de Atendimento

    {encarregadoHorario}

    @@ -402,27 +400,27 @@

    - As solicitações serão respondidas em até {configLGPD?.data?.prazoRespostaPadrao || 15} (quinze) dias, conforme previsto na - LGPD. + As solicitações serão respondidas em até {configLGPD?.data?.prazoRespostaPadrao || 15} (quinze) + dias, conforme previsto na LGPD.

    -
    +
    -

    10. Alterações nesta Política

    +

    10. Alterações nesta Política

    - Esta Política de Privacidade pode ser atualizada periodicamente. Recomendamos que - você revise esta página regularmente para estar ciente de quaisquer alterações. A data - da última atualização está indicada no topo desta página. + Esta Política de Privacidade pode ser atualizada periodicamente. Recomendamos que você + revise esta página regularmente para estar ciente de quaisquer alterações. A data da + última atualização está indicada no topo desta página.

    -
    - diff --git a/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte b/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte index 63a3de1..c7b8137 100644 --- a/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte +++ b/apps/web/src/routes/(dashboard)/privacidade/meus-dados/+page.svelte @@ -24,14 +24,14 @@ | 'revogacao_consentimento' | 'informacao_compartilhamento'; -let tipoSelecionado = $state(null); -let dadosSolicitados = $state(''); -let observacoes = $state(''); -let carregando = $state(false); + let tipoSelecionado = $state(null); + let dadosSolicitados = $state(''); + let observacoes = $state(''); + let carregando = $state(false); const client = useConvexClient(); const minhasSolicitacoesQuery = useQuery(api.lgpd.listarMinhasSolicitacoes, {}); - + // Garantir que sempre seja um array ou undefined const minhasSolicitacoes = $derived( minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null @@ -40,7 +40,7 @@ let carregando = $state(false); ? minhasSolicitacoesQuery : [] ); - + const exportarDados = useQuery(api.lgpd.exportarDadosUsuario, {}); const tiposSolicitacao: Array<{ valor: TipoSolicitacao; label: string; descricao: string }> = [ @@ -152,15 +152,15 @@ let carregando = $state(false); } -
    +
    -
    -
    - +
    +
    +
    -

    Meus Direitos LGPD

    +

    Meus Direitos LGPD

    Solicite o exercício dos seus direitos conforme a Lei Geral de Proteção de Dados

    @@ -168,22 +168,19 @@ let carregando = $state(false);
    -
    +
    -
    +
    -

    Nova Solicitação

    +

    Nova Solicitação

    - {#each tiposSolicitacao as tipo} @@ -241,8 +238,8 @@ let carregando = $state(false);

    - Sua solicitação será analisada e respondida em até 15 dias úteis, conforme - previsto na LGPD. + Sua solicitação será analisada e respondida em até 15 dias úteis, conforme previsto na + LGPD.

    @@ -251,24 +248,26 @@ let carregando = $state(false);
    -
    +

    Minhas Solicitações

    {#if minhasSolicitacoes && Array.isArray(minhasSolicitacoes)}
    - {minhasSolicitacoes.length} solicitação{minhasSolicitacoes.length !== 1 ? 'ões' : ''} + {minhasSolicitacoes.length} solicitação{minhasSolicitacoes.length !== 1 + ? 'ões' + : ''}
    {/if}
    {#if minhasSolicitacoes === undefined || minhasSolicitacoes === null} -
    +
    {:else if !Array.isArray(minhasSolicitacoes) || minhasSolicitacoes.length === 0} -
    - +
    +

    Nenhuma solicitação encontrada

    -

    +

    Suas solicitações aparecerão aqui após serem criadas

    @@ -277,16 +276,16 @@ let carregando = $state(false); {#each minhasSolicitacoes as solicitacao} {@const statusInfo = getStatusBadge(solicitacao.status)} {@const StatusIcon = getStatusIcon(solicitacao.status)} -
    -
    +
    +
    - +

    - {tiposSolicitacao.find((t) => t.valor === solicitacao.tipo) - ?.label || solicitacao.tipo} + {tiposSolicitacao.find((t) => t.valor === solicitacao.tipo)?.label || + solicitacao.tipo}

    -

    +

    Criada em{' '} {format(new Date(solicitacao.criadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR @@ -298,9 +297,9 @@ let carregando = $state(false);

    {#if solicitacao.resposta} -
    -

    Resposta:

    -

    {solicitacao.resposta}

    +
    +

    Resposta:

    +

    {solicitacao.resposta}

    {/if} @@ -318,7 +317,7 @@ let carregando = $state(false); {/if} {#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'} -
    +
    Prazo para resposta:{' '} {format(new Date(solicitacao.prazoResposta), 'dd/MM/yyyy', { locale: ptBR @@ -338,8 +337,8 @@ let carregando = $state(false);
    -

    Exportar Meus Dados

    -

    +

    Exportar Meus Dados

    +

    Baixe uma cópia completa dos seus dados pessoais em formato JSON.

    - @@ -313,7 +319,7 @@
    {:else}
    - +
    @@ -337,7 +343,7 @@
    {dispensa.funcionario?.nome || '-'}
    {#if dispensa.funcionario?.matricula} - + Mat: {dispensa.funcionario.matricula} {/if} @@ -348,7 +354,11 @@
    Início:{' '} - {formatarDataHora(dispensa.dataInicio, dispensa.horaInicio, dispensa.minutoInicio)} + {formatarDataHora( + dispensa.dataInicio, + dispensa.horaInicio, + dispensa.minutoInicio + )}
    Fim:{' '} @@ -389,11 +399,11 @@ {#if mostrandoModalExcluir && dispensaParaExcluir} {/if}
    - diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte index 0325dce..bc80580 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/homologacao/+page.svelte @@ -2,7 +2,17 @@ import { onMount } from 'svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Clock, Edit, TrendingUp, TrendingDown, Save, X, Trash2, Eye, MoreVertical } from 'lucide-svelte'; + import { + Clock, + Edit, + TrendingUp, + TrendingDown, + Save, + X, + Trash2, + Eye, + MoreVertical + } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto'; import { toast } from 'svelte-sonner'; @@ -19,7 +29,7 @@ let homologacaoParaExcluir = $state | null>(null); let mostrandoModalDetalhes = $state(false); let mostrandoModalExcluir = $state(false); - + // Filtros de período const hoje = new Date(); const trintaDiasAtras = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); @@ -28,7 +38,11 @@ // Monitorar mudanças em funcionarioSelecionado $effect(() => { - console.log('🔄 [DEBUG] funcionarioSelecionado mudou:', funcionarioSelecionado, typeof funcionarioSelecionado); + console.log( + '🔄 [DEBUG] funcionarioSelecionado mudou:', + funcionarioSelecionado, + typeof funcionarioSelecionado + ); }); // Formulário de edição @@ -95,7 +109,7 @@ return data.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', - year: 'numeric', + year: 'numeric' }); }); @@ -112,9 +126,9 @@ // Parâmetros reativos para queries const homologacoesParams = $derived({ - funcionarioId: funcionarioSelecionado || undefined, + funcionarioId: funcionarioSelecionado || undefined }); - + // Parâmetros para query de registros - só executa quando há funcionário selecionado const registrosQueryParams = $derived.by(() => { // Verificar se funcionarioSelecionado não é string vazia @@ -124,21 +138,19 @@ return { funcionarioId: funcionarioSelecionado as Id<'funcionarios'>, dataInicio: dataInicioFiltro, - dataFim: dataFimFiltro, + dataFim: dataFimFiltro }; }); const homologacoesQuery = useQuery(api.pontos.listarHomologacoes, homologacoesParams); const registrosQuery = $derived( - registrosQueryParams - ? useQuery(api.pontos.listarRegistrosPeriodo, registrosQueryParams) - : null + registrosQueryParams ? useQuery(api.pontos.listarRegistrosPeriodo, registrosQueryParams) : null ); const subordinados = $derived(subordinadosQuery?.data || []); const motivos = $derived(motivosQuery?.data); const homologacoes = $derived(homologacoesQuery?.data || []); - + // Registros já filtrados pela query no backend const registros = $derived.by(() => { if (!funcionarioSelecionado || funcionarioSelecionado === '' || !registrosQuery) { @@ -151,13 +163,18 @@ // A query do backend já filtra pelo funcionário, mas adicionamos verificação extra return dados.filter((r) => String(r.funcionarioId) === String(funcionarioSelecionado)); }); - + // Verificar se é gestor (tem subordinados) const isGestor = $derived(subordinados.length > 0); // Lista de funcionários do time const funcionarios = $derived.by(() => { - const funcs: Array<{ _id: Id<'funcionarios'>; nome: string; matricula?: string; fotoPerfilUrl?: string | null }> = []; + const funcs: Array<{ + _id: Id<'funcionarios'>; + nome: string; + matricula?: string; + fotoPerfilUrl?: string | null; + }> = []; for (const time of subordinados) { for (const membro of time.membros) { if (membro.funcionario && !funcs.find((f) => f._id === membro.funcionario._id)) { @@ -165,7 +182,7 @@ _id: membro.funcionario._id, nome: membro.funcionario.nome, matricula: membro.funcionario.matricula, - fotoPerfilUrl: membro.funcionario.fotoPerfilUrl, + fotoPerfilUrl: membro.funcionario.fotoPerfilUrl }); } } @@ -203,7 +220,7 @@ observacoes = ''; modoEdicao = true; abaAtiva = 'editar'; - + // Resetar campos de ajuste tipoAjuste = 'compensar'; const hoje = new Date().toISOString().split('T')[0]!; @@ -242,7 +259,7 @@ motivoId: motivoId || undefined, motivoTipo: motivoTipo || undefined, motivoDescricao: motivoDescricao || undefined, - observacoes: observacoes || undefined, + observacoes: observacoes || undefined }); toast.success('Registro editado com sucesso'); @@ -287,7 +304,7 @@ motivoId: motivoId || undefined, motivoTipo: motivoTipo || undefined, motivoDescricao: motivoDescricao || undefined, - observacoes: observacoes || undefined, + observacoes: observacoes || undefined }); toast.success('Banco de horas ajustado com sucesso'); @@ -323,7 +340,7 @@ try { await client.mutation(api.pontos.excluirHomologacao, { - homologacaoId: homologacaoParaExcluir, + homologacaoId: homologacaoParaExcluir }); toast.success('Homologação excluída com sucesso'); @@ -344,7 +361,9 @@ abrirEdicaoComAjuste(homologacao.registroId); } else { // Se for ajuste de banco de horas, não há como editar diretamente - toast.info('Ajustes de banco de horas não podem ser editados. Crie um novo ajuste para corrigir.'); + toast.info( + 'Ajustes de banco de horas não podem ser editados. Crie um novo ajuste para corrigir.' + ); } } @@ -356,23 +375,23 @@
    -
    +
    -
    - +
    +
    -

    Homologação de Registro

    +

    Homologação de Registro

    Edite registros de ponto e ajuste banco de horas

    -
    +

    Selecionar Funcionário

    -
    +
    Funcionário
    - +
    +
    + @@ -797,7 +855,9 @@ - +
    Data Tipo {getTipoRegistroLabel(registro.tipo)} {formatarHoraPonto(registro.hora, registro.minuto)}{formatarHoraPonto(registro.hora, registro.minuto)} Histórico de Homologações {#if funcionarioSelecionado} - - - Funcionário selecionado - + - Funcionário selecionado {:else} - + - Todas as homologações do seu time {/if} @@ -850,134 +908,140 @@ Nenhuma homologação encontrada {:else} -
    - - +
    +
    + + + + {#if !funcionarioSelecionado} + + {/if} + + + + + {#if isGestor} + + {/if} + + + + {#each homologacoes as homologacao} - + {#if !funcionarioSelecionado} - + {/if} - - - - + + + + {#if isGestor} - + {/if} - - - {#each homologacoes as homologacao} - - - {#if !funcionarioSelecionado} - - {/if} - - - - - {#if isGestor} - - {/if} - - {/each} - -
    DataFuncionárioTipoDetalhesMotivoObservaçõesAções
    Data + {new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')} + Funcionário +
    + +
    +
    {homologacao.funcionario?.nome || '-'}
    + {#if homologacao.funcionario?.matricula} + + Mat: {homologacao.funcionario.matricula} + + {/if} +
    +
    +
    TipoDetalhesMotivoObservações + {#if homologacao.registroId} + Edição de Registro + {:else if homologacao.tipoAjuste} + + Ajuste: {homologacao.tipoAjuste} + + {/if} + + {#if homologacao.horaAnterior !== undefined} +
    + + {formatarHoraPonto( + homologacao.horaAnterior, + homologacao.minutoAnterior || 0 + )} + + {' → '} + + {formatarHoraPonto( + homologacao.horaNova || 0, + homologacao.minutoNova || 0 + )} + +
    + {:else if homologacao.ajusteMinutos} +
    + {homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h{' '} + {homologacao.periodoMinutos || 0}min +
    + {/if} +
    +
    + {homologacao.motivoDescricao || homologacao.motivoTipo || '-'} +
    +
    +
    + {homologacao.observacoes || '-'} +
    +
    Ações +
    + + {#if homologacao.registroId} + + {/if} + +
    +
    - {new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')} - -
    - -
    -
    {homologacao.funcionario?.nome || '-'}
    - {#if homologacao.funcionario?.matricula} - - Mat: {homologacao.funcionario.matricula} - - {/if} -
    -
    -
    - {#if homologacao.registroId} - Edição de Registro - {:else if homologacao.tipoAjuste} - - Ajuste: {homologacao.tipoAjuste} - - {/if} - - {#if homologacao.horaAnterior !== undefined} -
    - - {formatarHoraPonto(homologacao.horaAnterior, homologacao.minutoAnterior || 0)} - - {' → '} - - {formatarHoraPonto(homologacao.horaNova || 0, homologacao.minutoNova || 0)} - -
    - {:else if homologacao.ajusteMinutos} -
    - {homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h{' '} - {homologacao.periodoMinutos || 0}min -
    - {/if} -
    -
    - {homologacao.motivoDescricao || homologacao.motivoTipo || '-'} -
    -
    -
    - {homologacao.observacoes || '-'} -
    -
    -
    - - {#if homologacao.registroId} - - {/if} - -
    -
    -
    - {/if} - + {/each} + +
    +
    + {/if}
    +
    {#if mostrandoModalDetalhes && homologacaoSelecionada}