diff --git a/apps/web/package.json b/apps/web/package.json index 7314345..44bda5f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "date-fns": "^4.1.0", "emoji-picker-element": "^1.27.0", "eslint": "catalog:", + "exceljs": "^4.4.0", "is-network-error": "^1.3.0", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", @@ -56,6 +57,8 @@ "lucide-svelte": "^0.552.0", "papaparse": "^5.4.1", "svelte-sonner": "^1.0.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0", "zod": "^4.1.12" } } \ No newline at end of file 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/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte index a0a2bb4..c1cb5a0 100644 --- a/apps/web/src/lib/components/ErrorModal.svelte +++ b/apps/web/src/lib/components/ErrorModal.svelte @@ -13,25 +13,28 @@ 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(); - const viewportWidth = window.innerWidth; + if (cardRef) { + const rect = cardRef.getBoundingClientRect(); const viewportHeight = window.innerHeight; - // Posicionar o modal na mesma posição do relógio sincronizado - // Centralizado horizontalmente no card do relógio - const left = rect.left + (rect.width / 2); - // Posicionar abaixo do card do relógio com um pequeno espaçamento - const top = rect.bottom + 20; + // 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: top, - left: left + top: finalTop, + left: window.innerWidth / 2 }; } @@ -75,37 +78,12 @@ // Função para obter estilo do modal baseado na posição calculada function getModalStyle() { if (modalPosition) { - // Garantir que o modal não saia da viewport - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const modalWidth = 700; // Aproximadamente max-w-2xl - const modalHeight = Math.min(viewportHeight * 0.9, 600); - - let left = modalPosition.left; - let top = modalPosition.top; - - // Ajustar se o modal sair da viewport à direita - if (left + (modalWidth / 2) > viewportWidth - 20) { - left = viewportWidth - (modalWidth / 2) - 20; - } - // Ajustar se o modal sair da viewport à esquerda - if (left - (modalWidth / 2) < 20) { - left = (modalWidth / 2) + 20; - } - // Ajustar se o modal sair da viewport abaixo - if (top + modalHeight > viewportHeight - 20) { - top = viewportHeight - modalHeight - 20; - } - // Ajustar se o modal sair da viewport acima - if (top < 20) { - top = 20; - } - - // Usar transform para centralizar horizontalmente baseado no left calculado - return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`; + // Posicionar na altura do card, centralizado horizontalmente + // position: fixed já é relativo à viewport, então podemos usar diretamente + return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`; } // Se não houver posição calculada, centralizar na tela - return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);'; + 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 diff --git a/apps/web/src/lib/components/FuncionarioMatriculaAutocomplete.svelte b/apps/web/src/lib/components/FuncionarioMatriculaAutocomplete.svelte new file mode 100644 index 0000000..41c2a12 --- /dev/null +++ b/apps/web/src/lib/components/FuncionarioMatriculaAutocomplete.svelte @@ -0,0 +1,128 @@ + + +
+ + +
+ + + +
+ + {#if mostrarDropdown && funcionariosFiltrados.length > 0} +
+ {#each funcionariosFiltrados as funcionario} + + {/each} +
+ {/if} + + {#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0} +
+
Nenhum funcionário encontrado
+
Você pode continuar digitando para buscar livremente
+
+ {/if} +
+ diff --git a/apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte b/apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte new file mode 100644 index 0000000..02635ba --- /dev/null +++ b/apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte @@ -0,0 +1,124 @@ + + +
+ + +
+ + + +
+ + {#if mostrarDropdown && funcionariosFiltrados.length > 0} +
+ {#each funcionariosFiltrados as funcionario} + + {/each} +
+ {/if} + + {#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0} +
+
Nenhum funcionário encontrado
+
Você pode continuar digitando para buscar livremente
+
+ {/if} +
+ diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 22a158e..c290ba7 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'; @@ -13,12 +13,14 @@ import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from 'lucide-svelte'; import { authClient } from '$lib/auth'; import { resolve } from '$app/paths'; + import { obterIPPublico } from '$lib/utils/deviceInfo'; 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 +124,133 @@ erroLogin = ''; carregandoLogin = true; - // const browserInfo = await getBrowserInfo(); + // 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 }, { - onError: (ctx) => { + 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); + } 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)); + + // 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, {}); + + if (usuario && usuario._id) { + await convexClient.mutation(api.logsLogin.registrarTentativaLogin, { + usuarioId: usuario._id, + matriculaOuEmail: matricula.trim(), + 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) + await convexClient.mutation(api.logsLogin.registrarTentativaLogin, { + matriculaOuEmail: matricula.trim(), + 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) { + console.error('Erro ao registrar tentativa de login:', err); + // Não bloquear o login se houver erro ao registrar + } + })(); + closeLoginModal(); goto(resolve('/')); } else { @@ -213,15 +330,12 @@
{#if currentUser.data} - -
- -
- - + + +
+ +
{:else}
- -
- - - - + diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index c93b0ed..721b06b 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -2,11 +2,11 @@ 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'; import logoGovPE from '$lib/assets/logo_governo_PE.png'; - import { onMount } from 'svelte'; interface Props { registroId: Id<'registrosPonto'>; @@ -21,25 +21,28 @@ 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(); - const viewportWidth = window.innerWidth; + if (cardRef) { + const rect = cardRef.getBoundingClientRect(); const viewportHeight = window.innerHeight; - // Posicionar o modal na mesma posição do relógio sincronizado - // Centralizado horizontalmente no card do relógio - const left = rect.left + (rect.width / 2); - // Posicionar abaixo do card do relógio com um pequeno espaçamento - const top = rect.bottom + 20; + // 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: top, - left: left + top: finalTop, + left: window.innerWidth / 2 }; } @@ -47,18 +50,26 @@ return null; } - onMount(() => { + // 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; + } else { + // Fallback para centralização + modalPosition = { + top: window.innerHeight / 2, + left: window.innerWidth / 2 + }; } }); }; - // Aguardar um pouco mais para garantir que o DOM está atualizado + // Aguardar um pouco para garantir que o DOM está atualizado setTimeout(updatePosition, 50); // Adicionar listener de scroll para atualizar posição @@ -73,42 +84,21 @@ window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleScroll); }; + } else { + // Limpar posição quando o modal for fechado + modalPosition = null; + } }); // Função para obter estilo do modal baseado na posição calculada function getModalStyle() { if (modalPosition) { - // Garantir que o modal não saia da viewport - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const modalWidth = 700; // Aproximadamente max-w-2xl - const modalHeight = Math.min(viewportHeight * 0.9, 600); - - let left = modalPosition.left; - let top = modalPosition.top; - - // Ajustar se o modal sair da viewport à direita - if (left + (modalWidth / 2) > viewportWidth - 20) { - left = viewportWidth - (modalWidth / 2) - 20; - } - // Ajustar se o modal sair da viewport à esquerda - if (left - (modalWidth / 2) < 20) { - left = (modalWidth / 2) + 20; - } - // Ajustar se o modal sair da viewport abaixo - if (top + modalHeight > viewportHeight - 20) { - top = viewportHeight - modalHeight - 20; - } - // Ajustar se o modal sair da viewport acima - if (top < 20) { - top = 20; - } - - // Usar transform para centralizar horizontalmente baseado no left calculado - return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`; + // Posicionar na altura do card, centralizado horizontalmente + // position: fixed já é relativo à viewport, então podemos usar diretamente + return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`; } // Se não houver posição calculada, centralizar na tela - return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);'; + return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;'; } async function gerarPDF() { @@ -120,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; @@ -136,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, { @@ -198,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) { @@ -227,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/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 0376321..ee67c42 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -63,8 +63,10 @@ let detalhesErroModal = $state(''); let justificativa = $state(''); let mostrandoModalConfirmacao = $state(false); + let mostrandoTransicao = $state(false); // Novo estado para transição 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 +206,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 +268,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 +279,7 @@ } // Registrar ponto + etapaProcessamento = 'registrando'; const resultado = await client.mutation(api.pontos.registrarPonto, { imagemId, informacoesDispositivo, @@ -314,6 +322,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 +401,7 @@ registrando = false; coletandoInfo = false; aguardandoProcessamento = false; + etapaProcessamento = null; } } @@ -453,7 +463,13 @@ console.warn('Erro ao obter tempo do servidor, usando tempo local:', error); atualizarDataHoraAtual(); } - mostrandoModalConfirmacao = true; + + // Mostrar transição antes da confirmação + mostrandoTransicao = true; + setTimeout(() => { + mostrandoTransicao = false; + mostrandoModalConfirmacao = true; + }, 1500); } } @@ -517,8 +533,17 @@ function confirmarRegistro() { mostrandoModalConfirmacao = false; - aguardandoProcessamento = true; - registrarPonto(); + mostrandoTransicao = true; // Mostrar transição antes do processamento + + setTimeout(() => { + mostrandoTransicao = false; + aguardandoProcessamento = true; + etapaProcessamento = 'coletando'; + // Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro + setTimeout(() => { + registrarPonto(); + }, 100); + }, 1500); } function cancelarRegistro() { @@ -852,28 +877,30 @@ const saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false); - // Posicionamento dos modais + // Posicionamento dos modais baseado no texto "Registrar Ponto" 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 do modal baseada no card de registro de ponto function calcularPosicaoModal() { - // Procurar pelo elemento do relógio sincronizado - const relogioRef = document.getElementById('relogio-sincronizado-ref'); + const cardRef = document.getElementById('card-registro-ponto-ref'); - if (relogioRef) { - const rect = relogioRef.getBoundingClientRect(); - const viewportWidth = window.innerWidth; + if (cardRef) { + const rect = cardRef.getBoundingClientRect(); const viewportHeight = window.innerHeight; - // Posicionar o modal na mesma posição do relógio sincronizado - // Centralizado horizontalmente no card do relógio - const left = rect.left + (rect.width / 2); - // Posicionar abaixo do card do relógio com um pequeno espaçamento - const top = rect.bottom + 20; + // Posicionar o modal na mesma altura Y do card (top do card) + // getBoundingClientRect() já retorna posição relativa à viewport quando usado com position: fixed + 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)); return { - top: top, - left: left + top: finalTop, + left: window.innerWidth / 2 }; } @@ -881,20 +908,26 @@ return null; } - // Atualizar posição quando os modais forem abertos ou quando a página rolar + // Atualizar posição quando os modais forem abertos $effect(() => { - if (mostrandoWebcam || mostrandoModalConfirmacao || aguardandoProcessamento || mostrarModalErro) { + if (mostrandoWebcam || mostrandoTransicao || aguardandoProcessamento || mostrandoModalConfirmacao) { // 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 mais para garantir que o DOM está atualizado + // Aguardar um pouco para garantir que o DOM está atualizado setTimeout(updatePosition, 50); // Adicionar listener de scroll para atualizar posição @@ -910,45 +943,20 @@ window.removeEventListener('resize', handleScroll); }; } else { - // Limpar posição quando os modais forem fechados + // Limpar posição quando o modal for fechado modalPosition = null; } }); - // Função para obter estilo do modal baseado na posição calculada + // Função para obter estilo do modal function getModalStyle() { if (modalPosition) { - // Garantir que o modal não saia da viewport - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const modalWidth = 800; // Aproximadamente max-w-2xl ou max-w-3xl - const modalHeight = Math.min(viewportHeight * 0.9, 600); - - let left = modalPosition.left; - let top = modalPosition.top; - - // Ajustar se o modal sair da viewport à direita - if (left + (modalWidth / 2) > viewportWidth - 20) { - left = viewportWidth - (modalWidth / 2) - 20; - } - // Ajustar se o modal sair da viewport à esquerda - if (left - (modalWidth / 2) < 20) { - left = (modalWidth / 2) + 20; - } - // Ajustar se o modal sair da viewport abaixo - if (top + modalHeight > viewportHeight - 20) { - top = viewportHeight - modalHeight - 20; - } - // Ajustar se o modal sair da viewport acima - if (top < 20) { - top = 20; - } - - // Usar transform para centralizar horizontalmente baseado no left calculado - return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`; + // Posicionar na altura do card, centralizado horizontalmente + // position: fixed já é relativo à viewport, então podemos usar diretamente + return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 800px;`; } - // Se não houver posição calculada, centralizar na tela - return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);'; + // Fallback para centralização padrão + return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 800px;'; } @@ -1009,7 +1017,7 @@
-
+
@@ -1479,6 +1487,43 @@
{/if} + + {#if mostrandoTransicao} + + {/if} + {#if aguardandoProcessamento}
- -

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 +1609,7 @@ @@ -1658,7 +1727,7 @@ +
@@ -777,8 +1489,13 @@ {#if eventosQuery?.data} + {@const eventosFiltradosPorFuncionario = filtroFuncionario + ? eventosQuery.data.filter(e => + e.funcionarioNome.toLowerCase().includes(filtroFuncionario.toLowerCase()) + ) + : eventosQuery.data}
- +
{/if} @@ -1016,282 +1733,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}
@@ -1300,46 +1769,69 @@
-

Registrar Atestado Médico

+
+
+ + + +
+
+

Registrar Atestado Médico

+

Preencha os dados do atestado médico do funcionário

+
+
+
+
-
- + Data Início * -
+
-
- Data Fim * -
+
-
- CID * -
+ @@ -1360,21 +1852,38 @@
-
- Observações -
+
-
- +
+
+ @@ -1392,33 +1915,56 @@
-

Registrar Declaração de Comparecimento

+
+
+ + + +
+
+

Registrar Declaração de Comparecimento

+

Preencha os dados da declaração de comparecimento

+
+
+
+
-
- + Data Início * -
+
-
- Data Fim * -
+
@@ -1438,24 +1984,55 @@
-
- Observações -
+
-
- - + @@ -1466,61 +2043,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 * -
+
-
- +
+
+ @@ -1582,38 +2213,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) -
+
@@ -1631,21 +2285,38 @@
-
- Observações -
+
-
- +
+
+
+ {:else if abaAtiva === 'relatorios'} + +
+
+
+
+ + + +
+
+

Imprimir Relatórios

+

Gere relatórios em PDF ou Excel com filtros personalizados

+
+
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ +
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+
{/if} 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 7fc1a56..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,13 +2,23 @@ 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'; 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'; + import jsPDF from 'jspdf'; + import autoTable from 'jspdf-autotable'; + import ExcelJS from 'exceljs'; + import logoGovPE from '$lib/assets/logo_governo_PE.png'; + import { format } from 'date-fns'; + import { ptBR } from 'date-fns/locale'; + import { toast } from 'svelte-sonner'; type CalendarConstructor = typeof import('@fullcalendar/core').Calendar; type CalendarInstance = import('@fullcalendar/core').Calendar; type EventInput = import('@fullcalendar/core').EventInput; @@ -22,6 +32,7 @@ matricula?: string | null; timeNome?: string | null; timeCor?: string | null; + gestorNome?: string | null; status: Solicitacao['status']; dataInicio: string; dataFim: string; @@ -39,6 +50,9 @@ const client = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser, {}); + // Estado da aba ativa + let abaAtiva = $state<'dashboard' | 'solicitacoes' | 'relatorios'>('dashboard'); + // Estado para controlar qual período está selecionado para mudança de status let periodoSelecionado = $state | null>(null); @@ -96,29 +110,60 @@ // Usar último valor válido ou array vazio const solicitacoes = $derived( - todasSolicitacoesQuery?.data ?? ultimasSolicitacoesValidas + (todasSolicitacoesQuery?.data ?? ultimasSolicitacoesValidas) || [] ); - let filtroStatus = $state('todos'); - let filtroNome = $state(''); - let filtroMatricula = $state(''); - let filtroEmail = $state(''); - let filtroMes = $state(''); - let filtroPeriodoInicio = $state(''); - let filtroPeriodoFim = $state(''); + // Filtros Dashboard + let filtroStatusDashboard = $state('todos'); + let filtroNomeDashboard = $state(''); + let filtroMatriculaDashboard = $state(''); + let filtroEmailDashboard = $state(''); + let filtroMesDashboard = $state(''); + let filtroPeriodoInicioDashboard = $state(''); + let filtroPeriodoFimDashboard = $state(''); + + // Filtros Solicitações + let filtroStatusSolicitacoes = $state('todos'); + let filtroNomeSolicitacoes = $state(''); + let filtroMatriculaSolicitacoes = $state(''); + let filtroEmailSolicitacoes = $state(''); + let filtroMesSolicitacoes = $state(''); + let filtroPeriodoInicioSolicitacoes = $state(''); + let filtroPeriodoFimSolicitacoes = $state(''); + + // Filtros Relatórios let dataInicioRelatorio = $state(''); let dataFimRelatorio = $state(''); + let filtroFuncionarioRelatorio = $state(''); + let filtroMatriculaRelatorio = $state(''); + let filtroStatusRelatorio = $state('todos'); + let filtroMesRelatorio = $state(''); + let gerandoRelatorio = $state(false); - // Filtrar períodos individuais - const solicitacoesFiltradas = $derived( - solicitacoes.filter((periodo) => { - if (filtroStatus !== 'todos' && periodo.status !== filtroStatus) { + // Função auxiliar para filtrar solicitações + function filtrarSolicitacoes( + lista: TodasSolicitacoes, + filtros: { + status: string; + nome: string; + matricula: string; + email: string; + mes: string; + periodoInicio: string; + periodoFim: string; + } + ): TodasSolicitacoes { + if (!Array.isArray(lista)) { + return []; + } + return lista.filter((periodo) => { + if (filtros.status !== 'todos' && periodo.status !== filtros.status) { return false; } - const nomeFiltro = normalizarTexto(filtroNome.trim()); - const matriculaFiltro = normalizarTexto(filtroMatricula.trim()); - const emailFiltro = normalizarTexto(filtroEmail.trim()); + const nomeFiltro = normalizarTexto(filtros.nome.trim()); + const matriculaFiltro = normalizarTexto(filtros.matricula.trim()); + const emailFiltro = normalizarTexto(filtros.email.trim()); if (nomeFiltro || matriculaFiltro || emailFiltro) { const funcionario = periodo.funcionario; @@ -153,18 +198,18 @@ } } - const aplicaMes = filtroMes !== ''; - const aplicaPeriodo = filtroPeriodoInicio !== '' || filtroPeriodoFim !== ''; + const aplicaMes = filtros.mes !== ''; + const aplicaPeriodo = filtros.periodoInicio !== '' || filtros.periodoFim !== ''; if (!aplicaMes && !aplicaPeriodo) { return true; } - const intervaloMes = aplicaMes ? criarIntervaloDoMes(filtroMes) : null; - const inicioFiltro = filtroPeriodoInicio - ? criarDataHora(filtroPeriodoInicio, 'inicio') + const intervaloMes = aplicaMes ? criarIntervaloDoMes(filtros.mes) : null; + const inicioFiltro = filtros.periodoInicio + ? criarDataHora(filtros.periodoInicio, 'inicio') : null; - const fimFiltro = filtroPeriodoFim ? criarDataHora(filtroPeriodoFim, 'fim') : null; + const fimFiltro = filtros.periodoFim ? criarDataHora(filtros.periodoFim, 'fim') : null; const inicioComparacao = inicioFiltro ?? new SvelteDate(-8640000000000000); const fimComparacao = fimFiltro ?? new SvelteDate(8640000000000000); @@ -187,6 +232,32 @@ } return true; + }); + } + + // Filtros para Dashboard + const solicitacoesFiltradasDashboard = $derived( + filtrarSolicitacoes(solicitacoes, { + status: filtroStatusDashboard, + nome: filtroNomeDashboard, + matricula: filtroMatriculaDashboard, + email: filtroEmailDashboard, + mes: filtroMesDashboard, + periodoInicio: filtroPeriodoInicioDashboard, + periodoFim: filtroPeriodoFimDashboard + }) + ); + + // Filtros para Solicitações + const solicitacoesFiltradas = $derived( + filtrarSolicitacoes(solicitacoes, { + status: filtroStatusSolicitacoes, + nome: filtroNomeSolicitacoes, + matricula: filtroMatriculaSolicitacoes, + email: filtroEmailSolicitacoes, + mes: filtroMesSolicitacoes, + periodoInicio: filtroPeriodoInicioSolicitacoes, + periodoFim: filtroPeriodoFimSolicitacoes }) ); @@ -203,21 +274,21 @@ }); 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, 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, @@ -235,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')}`; @@ -462,6 +537,132 @@ 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 + 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: SvelteDate; dataFim: SvelteDate; quantidade: number }> = []; + + for (let i = 0; i < 12; i++) { + 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(new Date(dataInicioMes.getTime()), 'MMM/yyyy', { locale: ptBR }); + meses.push({ + mes: mesLabel, + dataInicio: dataInicioMes, + dataFim: dataFimMes, + quantidade: 0 + }); + } + + // 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 SvelteSet(); + + 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)); + } + }); + + 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', @@ -516,7 +717,8 @@ calendarioInstance || calendarioInicializado || isLoading || - hasError + hasError || + abaAtiva !== 'dashboard' ) { return; } @@ -539,7 +741,8 @@ isLoading || hasError || calendarioInstance || - calendarioInicializado + calendarioInicializado || + abaAtiva !== 'dashboard' ) { return; } @@ -584,8 +787,32 @@ } } + // Monitorar mudanças de aba e destruir/reinicializar calendário + $effect(() => { + const abaAtual = abaAtiva; + + // Se não estiver na aba dashboard, destruir o calendário se existir + if (abaAtual !== 'dashboard') { + if (calendarioInstance) { + try { + calendarioInstance.destroy(); + calendarioInstance = null; + calendarioInicializado = false; + } catch (error) { + console.error('❌ [Calendário] Erro ao destruir:', error); + } + } + return; + } + }); + // Monitorar quando o container está disponível e os dados estão prontos $effect(() => { + // Verificar se a aba dashboard está ativa + if (abaAtiva !== 'dashboard') { + return; + } + // Verificar se não está mais carregando e não há erro const loading = isLoading; const error = hasError; @@ -596,8 +823,13 @@ return; } - // Se não tiver container ou já estiver inicializado, não fazer nada - if (!container || calendarioInicializado || calendarioInstance) { + // Se não tiver container, não fazer nada + if (!container) { + return; + } + + // Se já estiver inicializado, não fazer nada + if (calendarioInicializado || calendarioInstance) { return; } @@ -611,17 +843,29 @@ isLoading || hasError || calendarioInstance || - calendarioInicializado + calendarioInicializado || + abaAtiva !== 'dashboard' ) { return; } // Aguardar um pouco mais para garantir que o elemento está completamente renderizado setTimeout(() => { + // Verificar novamente antes de inicializar + if ( + !calendarioContainer || + isLoading || + hasError || + calendarioInstance || + calendarioInicializado || + abaAtiva !== 'dashboard' + ) { + return; + } inicializarCalendario().catch((error) => { console.error('❌ [Calendário] Erro ao inicializar:', error); }); - }, 100); + }, 300); })(); }); @@ -645,6 +889,11 @@ let timeoutAtualizacaoCalendario = $state | null>(null); $effect(() => { + // Não atualizar se não estiver na aba dashboard + if (abaAtiva !== 'dashboard') { + return; + } + // Não atualizar se o calendário não estiver inicializado ou estiver carregando if (!calendarioInstance || !calendarioInicializado || isLoading || hasError) { return; @@ -674,8 +923,8 @@ // Debounce para evitar atualizações muito frequentes timeoutAtualizacaoCalendario = setTimeout(() => { try { - // Verificar novamente se o calendário ainda está válido - if (!calendarioInstance || !calendarioInicializado) { + // Verificar novamente se o calendário ainda está válido e na aba correta + if (!calendarioInstance || !calendarioInicializado || abaAtiva !== 'dashboard') { return; } @@ -794,14 +1043,33 @@ .toLowerCase(); } - function limparTodosFiltros() { - filtroStatus = 'todos'; - filtroNome = ''; - filtroMatricula = ''; - filtroEmail = ''; - filtroMes = ''; - filtroPeriodoInicio = ''; - filtroPeriodoFim = ''; + function limparTodosFiltrosDashboard() { + filtroStatusDashboard = 'todos'; + filtroNomeDashboard = ''; + filtroMatriculaDashboard = ''; + filtroEmailDashboard = ''; + filtroMesDashboard = ''; + filtroPeriodoInicioDashboard = ''; + filtroPeriodoFimDashboard = ''; + } + + function limparTodosFiltrosSolicitacoes() { + filtroStatusSolicitacoes = 'todos'; + filtroNomeSolicitacoes = ''; + filtroMatriculaSolicitacoes = ''; + filtroEmailSolicitacoes = ''; + filtroMesSolicitacoes = ''; + filtroPeriodoInicioSolicitacoes = ''; + filtroPeriodoFimSolicitacoes = ''; + } + + function limparTodosFiltrosRelatorios() { + dataInicioRelatorio = ''; + dataFimRelatorio = ''; + filtroFuncionarioRelatorio = ''; + filtroMatriculaRelatorio = ''; + filtroStatusRelatorio = 'todos'; + filtroMesRelatorio = ''; } function handleRangeInicio(event: Event) { @@ -820,9 +1088,14 @@ rangeFimIndice = Math.max(valor, rangeInicioIndice); } - function limparPeriodoPersonalizado() { - filtroPeriodoInicio = ''; - filtroPeriodoFim = ''; + function limparPeriodoPersonalizadoDashboard() { + filtroPeriodoInicioDashboard = ''; + filtroPeriodoFimDashboard = ''; + } + + function limparPeriodoPersonalizadoSolicitacoes() { + filtroPeriodoInicioSolicitacoes = ''; + filtroPeriodoFimSolicitacoes = ''; } async function selecionarPeriodo(feriasId: Id<'ferias'>) { @@ -855,21 +1128,493 @@ ); } + // Função para obter dados do relatório aplicando todos os filtros + function obterDadosRelatorioFerias(): Array { + let inicio: Date; + let fim: Date; + let periodosSelecionados: Array; + + // Se houver mês de referência selecionado, usar o intervalo do mês + if (filtroMesRelatorio !== '') { + const intervaloMes = criarIntervaloDoMes(filtroMesRelatorio); + if (!intervaloMes) { + return []; + } + inicio = intervaloMes.inicio; + fim = intervaloMes.fim; + periodosSelecionados = periodosNoIntervalo(inicio, fim); + } else if (dataInicioRelatorio && dataFimRelatorio) { + // Se não houver mês, usar o período informado + inicio = converteParaData(dataInicioRelatorio); + fim = converteParaData(dataFimRelatorio); + + if (fim < inicio) { + return []; + } + + periodosSelecionados = periodosNoIntervalo(inicio, fim); + } else { + return []; + } + + // Aplicar filtros adicionais + if (filtroFuncionarioRelatorio.trim() !== '') { + const nomeFiltro = normalizarTexto(filtroFuncionarioRelatorio.trim()); + periodosSelecionados = periodosSelecionados.filter((periodo) => { + const nomeNormalizado = normalizarTexto(periodo.funcionarioNome); + return nomeNormalizado.includes(nomeFiltro); + }); + } + + if (filtroMatriculaRelatorio.trim() !== '') { + const matriculaFiltro = normalizarTexto(filtroMatriculaRelatorio.trim()); + periodosSelecionados = periodosSelecionados.filter((periodo) => { + const matriculaNormalizada = normalizarTexto(periodo.matricula ?? ''); + return matriculaNormalizada.includes(matriculaFiltro); + }); + } + + if (filtroStatusRelatorio !== 'todos') { + periodosSelecionados = periodosSelecionados.filter((periodo) => { + return periodo.status === filtroStatusRelatorio; + }); + } + + return periodosSelecionados; + } + + // Função para gerar PDF + async function gerarPDFFerias() { + const periodosSelecionados = obterDadosRelatorioFerias(); + + if (periodosSelecionados.length === 0) { + toast.error('Não há férias registradas para os filtros selecionados.'); + return; + } + + gerandoRelatorio = true; + try { + const doc = new jsPDF(); + + // Logo + 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 logoWidth = 30; + const aspectRatio = logoImg.height / logoImg.width; + const logoHeight = logoWidth * aspectRatio; + + doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight); + yPosition = Math.max(25, 10 + logoHeight / 2); + } catch (err) { + console.warn('Não foi possível carregar a logo:', err); + } + + // Título + doc.setFontSize(18); + doc.setTextColor(41, 128, 185); + doc.text('PROGRAMAÇÃO DE FÉRIAS', 105, yPosition, { align: 'center' }); + + yPosition += 10; + + // Período + let periodoTexto = ''; + if (filtroMesRelatorio !== '') { + const intervaloMes = criarIntervaloDoMes(filtroMesRelatorio); + if (intervaloMes) { + periodoTexto = `Período: ${format(intervaloMes.inicio, 'dd/MM/yyyy', { locale: ptBR })} até ${format(intervaloMes.fim, 'dd/MM/yyyy', { locale: ptBR })}`; + } + } else if (dataInicioRelatorio && dataFimRelatorio) { + periodoTexto = `Período: ${format(new Date(dataInicioRelatorio), 'dd/MM/yyyy', { locale: ptBR })} até ${format(new Date(dataFimRelatorio), 'dd/MM/yyyy', { locale: ptBR })}`; + } + + if (periodoTexto) { + doc.setFontSize(11); + doc.setTextColor(0, 0, 0); + doc.text(periodoTexto, 105, yPosition, { align: 'center' }); + yPosition += 8; + } + + // Filtros aplicados + doc.setFontSize(9); + doc.setTextColor(100, 100, 100); + let filtrosTexto = 'Filtros: '; + const filtros = []; + if (filtroFuncionarioRelatorio) filtros.push(`Nome: ${filtroFuncionarioRelatorio}`); + if (filtroMatriculaRelatorio) filtros.push(`Matrícula: ${filtroMatriculaRelatorio}`); + if (filtroStatusRelatorio !== 'todos') filtros.push(`Status: ${getStatusTexto(filtroStatusRelatorio)}`); + if (filtroMesRelatorio) { + const [ano, mes] = filtroMesRelatorio.split('-'); + const mesNome = new Date(Number(ano), Number(mes) - 1).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); + filtros.push(`Mês: ${mesNome}`); + } + filtrosTexto += filtros.length > 0 ? filtros.join('; ') : 'Nenhum filtro adicional'; + doc.text(filtrosTexto, 105, yPosition, { align: 'center', maxWidth: 180 }); + yPosition += 10; + + // Data de geração + doc.setFontSize(9); + doc.text(`Gerado em: ${format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR })}`, 15, yPosition); + yPosition += 12; + + // Preparar dados para tabela + const dadosTabela: string[][] = periodosSelecionados.map((periodo) => [ + periodo.funcionarioNome, + periodo.matricula ?? 'S/N', + periodo.gestorNome ?? 'Sem gestor', + periodo.anoReferencia.toString(), + formatarData(periodo.dataInicio), + formatarData(periodo.dataFim), + periodo.diasCorridos.toString(), + getStatusTexto(periodo.status) + ]); + + // Tabela + if (dadosTabela.length > 0) { + autoTable(doc, { + startY: yPosition, + head: [['Funcionário', 'Matrícula', 'Gestor', 'Ano Ref.', 'Início', 'Fim', 'Dias', 'Status']], + body: dadosTabela, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 7 + }, + styles: { fontSize: 7 }, + columnStyles: { + 0: { cellWidth: 40, fontSize: 7 }, // Funcionário + 1: { cellWidth: 20, fontSize: 7 }, // Matrícula + 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 + 6: { cellWidth: 12, fontSize: 7 }, // Dias + 7: { cellWidth: 18, fontSize: 7 } // Status + }, + margin: { top: yPosition, left: 10, right: 10 }, + tableWidth: 'wrap' + }); + + // Rodapé + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor(128, 128, 128); + doc.text( + `SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`, + doc.internal.pageSize.getWidth() / 2, + doc.internal.pageSize.getHeight() - 10, + { align: 'center' } + ); + } + } else { + doc.setFontSize(12); + doc.setTextColor(150, 150, 150); + doc.text('Nenhum registro encontrado para os filtros selecionados', 105, yPosition + 20, { + align: 'center' + }); + } + + // Salvar + const nomeArquivo = `programacao-ferias-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`; + doc.save(nomeArquivo); + toast.success('Relatório PDF gerado com sucesso!'); + } catch (error) { + console.error('Erro ao gerar PDF:', error); + toast.error('Erro ao gerar relatório PDF. Tente novamente.'); + } finally { + gerandoRelatorio = false; + } + } + + // Função para gerar Excel + async function gerarExcelFerias() { + const periodosSelecionados = obterDadosRelatorioFerias(); + + if (periodosSelecionados.length === 0) { + toast.error('Não há férias registradas para os filtros selecionados.'); + return; + } + + gerandoRelatorio = true; + try { + // Preparar dados + const dados: Array> = periodosSelecionados.map((periodo) => ({ + 'Funcionário': periodo.funcionarioNome, + 'Matrícula': periodo.matricula ?? 'S/N', + 'Gestor': periodo.gestorNome ?? 'Sem gestor', + 'Ano Ref.': periodo.anoReferencia, + 'Data Início': formatarData(periodo.dataInicio), + 'Data Fim': formatarData(periodo.dataFim), + 'Dias': periodo.diasCorridos, + 'Status': getStatusTexto(periodo.status) + })); + + // Criar workbook com ExcelJS + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Programação de Férias'); + + // Obter cabeçalhos + const headers = Object.keys(dados[0] || {}); + + // Carregar logo + let logoBuffer: ArrayBuffer | null = null; + try { + const response = await fetch(logoGovPE); + if (response.ok) { + logoBuffer = await response.arrayBuffer(); + } else { + const logoImg = new Image(); + logoImg.crossOrigin = 'anonymous'; + logoImg.src = logoGovPE; + await new Promise((resolve, reject) => { + logoImg.onload = () => resolve(); + logoImg.onerror = () => reject(); + setTimeout(() => reject(), 3000); + }); + + const canvas = document.createElement('canvas'); + canvas.width = logoImg.width; + canvas.height = logoImg.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(logoImg, 0, 0); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Falha ao converter imagem')); + }, 'image/png'); + }); + logoBuffer = await blob.arrayBuffer(); + } + } + } catch (err) { + console.warn('Não foi possível carregar a logo:', err); + } + + // Linha 1: Cabeçalho com logo e título + worksheet.mergeCells('A1:B1'); + const logoCell = worksheet.getCell('A1'); + logoCell.alignment = { vertical: 'middle', horizontal: 'left' }; + logoCell.border = { + right: { style: 'thin', color: { argb: 'FFE0E0E0' } } + }; + + // Adicionar logo se disponível + if (logoBuffer) { + const logoId = workbook.addImage({ + buffer: new Uint8Array(logoBuffer), + extension: 'png' + }); + worksheet.addImage(logoId, { + tl: { col: 0, row: 0 }, + ext: { width: 140, height: 55 } + }); + } + + // Mesclar C1 até última coluna para título + const lastCol = String.fromCharCode(65 + headers.length - 1); + worksheet.mergeCells(`C1:${lastCol}1`); + const titleCell = worksheet.getCell('C1'); + titleCell.value = 'PROGRAMAÇÃO DE FÉRIAS'; + titleCell.font = { bold: true, size: 18, color: { argb: 'FF2980B9' } }; + titleCell.alignment = { vertical: 'middle', horizontal: 'center' }; + + // Ajustar altura da linha 1 para acomodar a logo + worksheet.getRow(1).height = 60; + + // Linha 2: Cabeçalhos da tabela + headers.forEach((header, index) => { + const cell = worksheet.getCell(2, index + 1); + cell.value = header; + cell.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF2980B9' } + }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = { + top: { style: 'thin', color: { argb: 'FF000000' } }, + bottom: { style: 'thin', color: { argb: 'FF000000' } }, + left: { style: 'thin', color: { argb: 'FF000000' } }, + right: { style: 'thin', color: { argb: 'FF000000' } } + }; + }); + + // Ajustar altura da linha 2 + worksheet.getRow(2).height = 25; + + // Linhas 3+: Dados + dados.forEach((rowData, rowIndex) => { + const row = rowIndex + 3; + headers.forEach((header, colIndex) => { + const cell = worksheet.getCell(row, colIndex + 1); + cell.value = rowData[header]; + + // Cor de fundo alternada (zebra striping) + const isEvenRow = rowIndex % 2 === 1; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: isEvenRow ? 'FFF8F9FA' : 'FFFFFFFF' } + }; + + // Alinhamento + let alignment: 'left' | 'center' | 'right' = 'left'; + if (header === 'Dias' || header === 'Data Início' || header === 'Data Fim' || header === 'Status' || header === 'Ano Ref.') { + alignment = 'center'; + } + cell.alignment = { vertical: 'middle', horizontal: alignment, wrapText: true }; + + // Fonte + cell.font = { size: 10, color: { argb: 'FF000000' } }; + + // Bordas + cell.border = { + top: { style: 'thin', color: { argb: 'FFE0E0E0' } }, + bottom: { style: 'thin', color: { argb: 'FFE0E0E0' } }, + left: { style: 'thin', color: { argb: 'FFE0E0E0' } }, + right: { style: 'thin', color: { argb: 'FFE0E0E0' } } + }; + + // Formatação especial para Status + if (header === 'Status') { + const statusValue = rowData[header]; + if (statusValue === 'Aprovado' || statusValue === 'Ajustado') { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFD4EDDA' } + }; + cell.font = { size: 10, color: { argb: 'FF155724' } }; + } else if (statusValue === 'Reprovado' || statusValue === 'Cancelado RH') { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF8D7DA' } + }; + cell.font = { size: 10, color: { argb: 'FF721C24' } }; + } else if (statusValue === 'Aguardando') { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFF3CD' } + }; + cell.font = { size: 10, color: { argb: 'FF856404' } }; + } + } + }); + }); + + // Ajustar largura das colunas + worksheet.columns = [ + { width: 30 }, // Funcionário + { width: 15 }, // Matrícula + { width: 20 }, // Time + { width: 12 }, // Ano Ref. + { width: 12 }, // Data Início + { width: 12 }, // Data Fim + { width: 8 }, // Dias + { width: 15 } // Status + ]; + + // Congelar linha 2 (cabeçalho da tabela) + worksheet.views = [ + { + state: 'frozen', + ySplit: 2, + topLeftCell: 'A3', + activeCell: 'A3' + } + ]; + + // Gerar arquivo + const nomeArquivo = `programacao-ferias-${format(new Date(), 'yyyy-MM-dd-HHmm')}.xlsx`; + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = nomeArquivo; + link.click(); + window.URL.revokeObjectURL(url); + + toast.success('Relatório Excel gerado com sucesso!'); + } catch (error) { + console.error('Erro ao gerar Excel:', error); + toast.error('Erro ao gerar relatório Excel. Tente novamente.'); + } finally { + gerandoRelatorio = false; + } + } + function gerarRelatorioImpressao() { - if (!dataInicioRelatorio || !dataFimRelatorio) { - window.alert('Informe o período para gerar a programação de férias.'); + let inicio: Date; + let fim: Date; + let periodosSelecionados: Array; + + // Se houver mês de referência selecionado, usar o intervalo do mês + if (filtroMesRelatorio !== '') { + const intervaloMes = criarIntervaloDoMes(filtroMesRelatorio); + if (!intervaloMes) { + window.alert('Mês de referência inválido.'); + return; + } + inicio = intervaloMes.inicio; + fim = intervaloMes.fim; + periodosSelecionados = periodosNoIntervalo(inicio, fim); + } else if (dataInicioRelatorio && dataFimRelatorio) { + // Se não houver mês, usar o período informado + inicio = converteParaData(dataInicioRelatorio); + fim = converteParaData(dataFimRelatorio); + + if (fim < inicio) { + window.alert('A data final não pode ser anterior à data inicial.'); + return; + } + + periodosSelecionados = periodosNoIntervalo(inicio, fim); + } else { + window.alert('Informe o período ou selecione um mês de referência para gerar a programação de férias.'); return; } - const inicio = converteParaData(dataInicioRelatorio); - const fim = converteParaData(dataFimRelatorio); - - if (fim < inicio) { - window.alert('A data final não pode ser anterior à data inicial.'); - return; + // Aplicar filtros adicionais + if (filtroFuncionarioRelatorio.trim() !== '') { + const nomeFiltro = normalizarTexto(filtroFuncionarioRelatorio.trim()); + periodosSelecionados = periodosSelecionados.filter((periodo) => { + const nomeNormalizado = normalizarTexto(periodo.funcionarioNome); + return nomeNormalizado.includes(nomeFiltro); + }); } - const periodosSelecionados = periodosNoIntervalo(inicio, fim); + if (filtroMatriculaRelatorio.trim() !== '') { + const matriculaFiltro = normalizarTexto(filtroMatriculaRelatorio.trim()); + periodosSelecionados = periodosSelecionados.filter((periodo) => { + const matriculaNormalizada = normalizarTexto(periodo.matricula ?? ''); + return matriculaNormalizada.includes(matriculaFiltro); + }); + } + + if (filtroStatusRelatorio !== 'todos') { + periodosSelecionados = periodosSelecionados.filter((periodo) => { + return periodo.status === filtroStatusRelatorio; + }); + } + + // Nota: O filtro de mês de referência já foi aplicado no início da função quando usado como período principal + // Se o mês foi usado como período principal, não precisa filtrar novamente if (periodosSelecionados.length === 0) { window.alert('Não há férias registradas dentro do período informado.'); @@ -884,7 +1629,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)} @@ -992,7 +1737,7 @@ # Funcionário Matrícula - Time + Gestor Ano Ref. Início Fim @@ -1181,6 +1926,70 @@
+ +
+ + + +
+ {#if hasError}
@@ -1204,8 +2013,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 +2164,41 @@
-

Filtros

+
+
+ + + +
+
+

Filtros

+

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

+
+
@@ -1351,17 +2214,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 +2250,18 @@

@@ -1419,18 +2282,18 @@

@@ -1451,18 +2314,18 @@

@@ -1483,17 +2346,17 @@

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

@@ -1538,8 +2401,8 @@
@@ -1554,114 +2417,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} +
+ +
+
+
+
+ + + +
+
+

+ 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 && chartDataFuncionariosFerias.labels.length > 0} + + {:else} +
+

Carregando dados do gráfico...

+
+ {/if} +
+
+
+
+ {/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}

@@ -1742,10 +2862,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}
@@ -1816,29 +2944,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/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 atividades.data as atividade} + {#each atividades as atividade} - {#each logins.data as login} + {#each logins as login} + 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 08c13ad..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,1484 +1,1780 @@ -
-
-
-

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} -
+ +
+
+
+ + + +
+
+

Central de Chamados

+

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

+
+
+
-
-
-
-

Painel de chamados

-

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

-
-
- - - -
-
+ +
+ + + +
-
-
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/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 84f40d8..2231233 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', @@ -433,15 +433,16 @@
{#each featureCards as card (card.title)} - + {/if} {/each}
diff --git a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte index 85e4d2e..6979b04 100644 --- a/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte @@ -1,26 +1,190 @@
@@ -163,13 +532,38 @@
- - +
+ + + {#if mostrarFiltros} +
+
+
+ + +
+
+ + +
+
+ + +
+ {#if abaAtiva === "logins"} +
+ + +
+
+ + +
+ {/if} +
+ +
+
+ {/if} @@ -191,17 +654,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 +685,7 @@
@@ -277,17 +740,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 +767,14 @@ Usuário/Email Status IP + Localização Dispositivo Navegador Sistema
@@ -321,11 +785,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}
@@ -353,6 +833,17 @@ {login.ipAddress || "-"} +
+ + + + + + {formatarLocalizacao(login)} + +
+
{login.device || "-"}
- - - - - - - - - - - - {#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 === 'dashboard'} +
+
+

Total de chamados

+

{estatisticas.total ?? 0}

+
+
+

Abertos

+

{estatisticas.abertos ?? 0}

+
+
+

Em andamento

+

{estatisticas.emAndamento ?? 0}

+
+
+

Vencidos/Cancelados

+

{estatisticas.vencidos ?? 0}

+
+
- - {#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} -
-
-
-
+ +
+
+
+

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} +
- -
- -
-
- - - -

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 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} - -
-
- - - -

Responder chamado

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

Painel de chamados

+

Filtros por status, responsável e setor.

+
+
+ + + +
+
-
- - - {#if respostaAnexoNome} -
- {respostaAnexoNome} - -
- {/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 respostaFeedback} -
- {respostaFeedback} -
- {/if} + + {#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} +
+
+
+
-
- - -
-
-
+ +
+ +
+
+ + + +

Detalhes do chamado

+
+
+
+

Descrição

+

+ {detalheSelecionado.descricao} +

+
+
+
+

Prazo de resposta

+

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

+
+
+

Prazo de conclusão

+

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

+
+
+
+

Histórico e Timeline

+ +
+
+
- -
-
- - - -

Atribuir responsável

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

Responder chamado

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

Prorrogar prazo

-

Recurso exclusivo para a equipe de TI

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

SLAs Configurados

-

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

-
-
+ {#if respostaFeedback} +
+ {respostaFeedback} +
+ {/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
-
-
+
+ + +
+
+
- -
- - - - - - - - - - - - - - - {#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} - + +
+
+ + + +

Atribuir responsável

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

Configuração de SLA por Prioridade

-

Configure SLAs separados para cada nível de prioridade

-
+ +
+
+ + + +
+

Prorrogar prazo

+

Recurso exclusivo para a equipe de TI

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ {#if prorrogacaoFeedback} +
+ {prorrogacaoFeedback} +
+ {/if} + +
+
+
+ + {/if} + {/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} -
+ + {#if abaAtiva === 'sla'} + +
+
+
+

SLAs Configurados

+

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

+
+
- -
-

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

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

{slaFeedback}

-
- {/if} -
- - {#if slaForm.slaId} - - {/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 slaParaExcluir} - - {/if} + {#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 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} + + + +
+
+

+ Configuração de SLA por Prioridade +

+

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

+
+ + +
+ {#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/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

diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte index 7ed103d..abaef3e 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-jitsi/+page.svelte @@ -7,35 +7,14 @@ const currentUser = useQuery(api.auth.getCurrentUser, {}); const configAtual = useQuery(api.configuracaoJitsi.obterConfigJitsi, {}); - // Query condicional para configuração completa - const configCompletaQuery = $derived( - configAtual?.data?._id ? { configId: configAtual.data._id } : null - ); - const configCompleta = useQuery( - api.configuracaoJitsi.obterConfigJitsiCompleta, - configCompletaQuery ? configCompletaQuery : 'skip' - ); - let domain = $state(''); let appId = $state('sgse-app'); let roomPrefix = $state('sgse'); let useHttps = $state(false); let acceptSelfSignedCert = $state(false); - // Campos SSH/Docker - let sshHost = $state(''); - let sshPort = $state(22); - let sshUsername = $state(''); - let sshPassword = $state(''); - let sshKeyPath = $state(''); - let dockerComposePath = $state(''); - let jitsiConfigPath = $state('~/.jitsi-meet-cfg'); - - let mostrarConfigSSH = $state(false); let processando = $state(false); let testando = $state(false); - let testandoSSH = $state(false); - let aplicandoServidor = $state(false); let mensagem = $state<{ tipo: 'success' | 'error'; texto: string; detalhes?: string } | null>( null ); @@ -65,19 +44,6 @@ } }); - // Carregar configurações SSH/Docker - $effect(() => { - if (configCompleta?.data) { - sshHost = configCompleta.data.sshHost || ''; - sshPort = configCompleta.data.sshPort || 22; - sshUsername = configCompleta.data.sshUsername || ''; - sshPassword = ''; // Sempre limpar senha por segurança - sshKeyPath = configCompleta.data.sshKeyPath || ''; - dockerComposePath = configCompleta.data.dockerComposePath || ''; - jitsiConfigPath = configCompleta.data.jitsiConfigPath || '~/.jitsi-meet-cfg'; - mostrarConfigSSH = !!(configCompleta.data.sshHost || configCompleta.data.sshUsername); - } - }); // Ativar HTTPS automaticamente se domínio contém porta 8443 $effect(() => { @@ -117,15 +83,7 @@ roomPrefix: roomPrefix.trim(), useHttps, acceptSelfSignedCert, - configuradoPorId: currentUser.data._id as Id<'usuarios'>, - // Configurações SSH/Docker (opcionais) - sshHost: sshHost.trim() || undefined, - sshPort: sshPort || undefined, - sshUsername: sshUsername.trim() || undefined, - sshPassword: sshPassword.trim() || undefined, - sshKeyPath: sshKeyPath.trim() || undefined, - dockerComposePath: dockerComposePath.trim() || undefined, - jitsiConfigPath: jitsiConfigPath.trim() || undefined + configuradoPorId: currentUser.data._id as Id<'usuarios'> }); if (resultado.sucesso) { @@ -173,96 +131,8 @@ } } - async function testarConexaoSSH() { - if (!sshHost?.trim() || !sshUsername?.trim()) { - mostrarMensagem('error', 'Preencha Host e Usuário SSH antes de testar'); - return; - } - - if (!sshPassword?.trim() && !sshKeyPath?.trim()) { - mostrarMensagem('error', 'Preencha a senha SSH ou o caminho da chave antes de testar'); - return; - } - - testandoSSH = true; - try { - const resultado = await client.action(api.actions.jitsiServer.testarConexaoSSH, { - sshHost: sshHost.trim(), - sshPort: sshPort || 22, - sshUsername: sshUsername.trim(), - sshPassword: sshPassword.trim() || undefined, - sshKeyPath: sshKeyPath.trim() || undefined - }); - - if (resultado.sucesso) { - mostrarMensagem('success', resultado.mensagem); - } else { - mostrarMensagem('error', `Erro ao testar SSH: ${resultado.erro}`); - } - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Erro ao testar SSH:', error); - mostrarMensagem('error', errorMessage || 'Erro ao conectar via SSH'); - } finally { - testandoSSH = false; - } - } - - async function aplicarConfiguracaoServidor() { - if (!configAtual?.data?._id) { - mostrarMensagem('error', 'Salve a configuração básica antes de aplicar no servidor'); - return; - } - - if (!sshHost?.trim() || !sshUsername?.trim()) { - mostrarMensagem('error', 'Configure o acesso SSH antes de aplicar no servidor'); - return; - } - - // Senha SSH é necessária para aplicar (pode ser a armazenada ou uma nova) - if (!sshPassword?.trim() && !sshKeyPath?.trim() && !configCompleta?.data?.sshPasswordHash) { - mostrarMensagem( - 'error', - 'Forneça a senha SSH ou o caminho da chave para aplicar a configuração' - ); - return; - } - - if ( - !confirm( - 'Deseja aplicar essas configurações no servidor Jitsi Docker? Os containers serão reiniciados.' - ) - ) { - return; - } - - aplicandoServidor = true; - try { - const resultado = await client.action(api.actions.jitsiServer.aplicarConfiguracaoServidor, { - configId: configAtual.data._id, - sshPassword: sshPassword.trim() || undefined - }); - - if (resultado.sucesso) { - mostrarMensagem('success', resultado.mensagem, resultado.detalhes); - // Limpar senha após uso - sshPassword = ''; - } else { - mostrarMensagem('error', `Erro ao aplicar configuração: ${resultado.erro}`); - } - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Erro ao aplicar configuração:', error); - mostrarMensagem('error', errorMessage || 'Erro ao aplicar configuração no servidor'); - } finally { - aplicandoServidor = false; - } - } - const statusConfig = $derived(configAtual?.data?.ativo ? 'Configurado' : 'Não configurado'); - const configuradoNoServidor = $derived(configCompleta?.data?.configuradoNoServidor ?? false); - const isLoading = $derived(configAtual === undefined); const hasError = $derived(configAtual === null && !isLoading); @@ -478,218 +348,6 @@

- -
-
-

Configuração SSH/Docker (Opcional)

- -
- - {#if mostrarConfigSSH} -
- -
- - -
- Endereço do servidor Docker -
-
- - -
- - -
- - -
- - -
- - -
- - -
- Ou use caminho da chave SSH abaixo -
-
- - -
- - -
- Caminho no servidor SSH para a chave privada -
-
- - -
- - -
- Diretório com docker-compose.yml -
-
- - -
- - -
- Diretório de configurações do Jitsi -
-
-
- - - {#if configuradoNoServidor} -
- - - - - Configuração aplicada no servidor - {#if configCompleta?.data?.configuradoNoServidorEm} - em {new Date(configCompleta.data.configuradoNoServidorEm).toLocaleString('pt-BR')} - {/if} - -
- {/if} - - -
- - - -
- {/if}
diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 498b105..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, {}); @@ -95,6 +97,37 @@ return []; }); + const totalUsuarios = $derived(usuarios.length); + const totalTemplates = $derived(templates.length); + + 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); @@ -134,6 +167,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'; @@ -208,6 +244,29 @@ 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 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 function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) { mensagem = { tipo, texto }; @@ -681,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 || '' }) @@ -882,10 +942,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 || '' }) @@ -1098,14 +1158,17 @@ } -
+
+
-
+
-
+

Notificações e Mensagens

-

Enviar notificações para usuários do sistema

+

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

+
+
+
+
+
Usuários alcançáveis
+
+ {totalUsuarios} +
+
+
+
Templates cadastrados
+
+ {totalTemplates} +
@@ -1173,6 +1253,36 @@
{/if} + +
+ + + +
+ + + {#if abaAtiva === 'enviar'}
@@ -1281,8 +1391,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)} @@ -1645,7 +1755,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 +1995,7 @@ {/if}
+ {/if}
@@ -1882,6 +2014,7 @@ Para enviar emails, certifique-se de configurar o SMTP em Configurações de Email.
+
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..ed24f76 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/+page.svelte @@ -0,0 +1,322 @@ + + +
+
+ +
+
+
+ + + +
+
+

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} +
+ + + + + + + + + + + + + {#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 variavel} + {{variavel}} + {/each} + {#if template.variaveis.length > 3} + +{template.variaveis.length - 3} + {/if} +
+ {:else} + - + {/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 new file mode 100644 index 0000000..071b4ce --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/[id]/+page.svelte @@ -0,0 +1,324 @@ + + +
+
+
+
+
+ + + +
+
+

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 +
+ + {#if carregandoTemplate} +
+ +

Carregando template...

+
+ {:else if erroTemplate} +
+ 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} +
+ {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..4c32f13 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/templates/novo/+page.svelte @@ -0,0 +1,261 @@ + + +
+
+
+
+
+ + + +
+
+

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 +
+ + {#if mensagem} +
+ {mensagem.texto} + +
+ {/if} + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ + + +
+
+ + +
+
+ +
+ Cancelar + +
+
+
+
diff --git a/bun.lock b/bun.lock index 56bacb1..83a804e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "sgse-app", @@ -48,6 +47,7 @@ "date-fns": "^4.1.0", "emoji-picker-element": "^1.27.0", "eslint": "catalog:", + "exceljs": "^4.4.0", "is-network-error": "^1.3.0", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", @@ -55,6 +55,8 @@ "lucide-svelte": "^0.552.0", "papaparse": "^5.4.1", "svelte-sonner": "^1.0.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0", "zod": "^4.1.12", }, "devDependencies": { @@ -348,6 +350,10 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="], + + "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="], + "@fullcalendar/core": ["@fullcalendar/core@6.1.19", "", { "dependencies": { "preact": "~10.12.1" } }, "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ=="], "@fullcalendar/daygrid": ["@fullcalendar/daygrid@6.1.19", "", { "peerDependencies": { "@fullcalendar/core": "~6.1.19" } }, "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ=="], @@ -690,12 +696,18 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], + + "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -716,6 +728,8 @@ "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], @@ -728,12 +742,22 @@ "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], "better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="], "better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], + "bowser": ["bowser@2.12.1", "", {}, "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -742,6 +766,14 @@ "browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="], + + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -754,6 +786,10 @@ "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], @@ -762,12 +798,18 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@2.17.1", "", {}, "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg=="], + "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], + "compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "convex": ["convex@1.29.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-uoIPXRKIp2eLCkkR9WJ2vc9NtgQtx8Pml59WPUahwbrd5EuW2WLI/cf2E7XrUzOSifdQC3kJZepisk4wJNTJaA=="], @@ -780,6 +822,12 @@ "core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], @@ -796,6 +844,8 @@ "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -820,10 +870,14 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.243", "", {}, "sha512-ZCphxFW3Q1TVhcgS9blfut1PX8lusVi2SvXQgmEEnK4TCmE1JhH2JkjJN+DNt0pJJwfBri5AROBnz2b/C+YU9g=="], "emoji-picker-element": ["emoji-picker-element@1.27.0", "", {}, "sha512-CeN9g5/kq41+BfYPDpAbE2ejZRHbs1faFDmU9+E9wGA4JWLkok9zo1hwcAFnUhV4lPR3ZuLHiJxNG1mpjoF4TQ=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], @@ -880,6 +934,12 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="], + + "exit-on-epipe": ["exit-on-epipe@1.0.1", "", {}, "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw=="], + + "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -910,10 +970,18 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -928,6 +996,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -956,12 +1026,20 @@ "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], @@ -1046,6 +1124,8 @@ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -1054,10 +1134,14 @@ "kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lib-jitsi-meet": ["lib-jitsi-meet@1.0.6", "", {}, "sha512-Hnp8F7btmIFBGh5hgli1uTzb7c7IgWBgTMFu4GnSasE8sx23RcTerXBjH+XZcsGsxnoW3pFKlU77za1a0o3qhw=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -1084,12 +1168,40 @@ "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], + + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + + "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="], + + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + + "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="], + + "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lucide-svelte": ["lucide-svelte@0.552.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w=="], @@ -1104,6 +1216,8 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1120,6 +1234,8 @@ "nodemailer": ["nodemailer@7.0.10", "", {}, "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1136,6 +1252,8 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -1144,7 +1262,7 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], @@ -1152,6 +1270,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1186,6 +1306,10 @@ "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.1", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ=="], + "printj": ["printj@1.1.2", "", { "bin": { "printj": "./bin/printj.njs" } }, "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1204,6 +1328,10 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], @@ -1224,6 +1352,8 @@ "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], + "rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="], "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], @@ -1238,10 +1368,14 @@ "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -1254,6 +1388,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1270,6 +1406,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1284,6 +1422,8 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], @@ -1312,14 +1452,20 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1362,6 +1508,8 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="], + "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1370,6 +1518,8 @@ "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "vite": ["vite@7.1.12", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], @@ -1386,14 +1536,28 @@ "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + + "xlsx-js-style": ["xlsx-js-style@1.2.0", "", { "dependencies": { "adler-32": "~1.2.0", "cfb": "^1.1.4", "codepage": "~1.14.0", "commander": "~2.17.1", "crc-32": "~1.2.0", "exit-on-epipe": "~1.0.1", "fflate": "^0.3.8", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -1408,6 +1572,10 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + + "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + "@mmailaender/convex-better-auth-svelte/convex-svelte": ["convex-svelte@0.0.11", "", { "peerDependencies": { "convex": "^1.10.0", "svelte": "^5.0.0" } }, "sha512-N/29gg5Zqy72vKL4xHSLk3jGwXVKIWXPs6xzq6KxGL84y/D6hG85pG2CPOzn08EzMmByts5FTkJ5p3var6yDng=="], "@sveltejs/kit/@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -1434,18 +1602,42 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "convex-svelte/runed": ["runed@0.31.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ=="], + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fast-png/pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "xlsx-js-style/adler-32": ["adler-32@1.2.0", "", { "dependencies": { "exit-on-epipe": "~1.0.1", "printj": "~1.1.0" }, "bin": { "adler32": "./bin/adler32.njs" } }, "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ=="], + + "xlsx-js-style/codepage": ["codepage@1.14.0", "", { "dependencies": { "commander": "~2.14.1", "exit-on-epipe": "~1.0.1" }, "bin": { "codepage": "./bin/codepage.njs" } }, "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw=="], + + "xlsx-js-style/fflate": ["fflate@0.3.11", "", {}, "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A=="], + + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -1462,6 +1654,10 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "archiver-utils/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -1512,6 +1708,26 @@ "convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], + "duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "jszip/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "unzipper/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "xlsx-js-style/codepage/commander": ["commander@2.14.1", "", {}, "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index d5115b6..6ea5997 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -61,7 +61,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 { @@ -124,7 +127,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..89078d2 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -358,20 +358,48 @@ 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 (agendado via scheduler) + try { + await ctx.scheduler.runAfter(0, 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 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, + 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 +503,47 @@ 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 (agendado via scheduler) + try { + await ctx.scheduler.runAfter(0, 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 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, + 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 +649,49 @@ 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 (agendado via scheduler) + try { + await ctx.scheduler.runAfter(0, 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 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, + 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..d0c5504 100644 --- a/packages/backend/convex/chamados.ts +++ b/packages/backend/convex/chamados.ts @@ -121,15 +121,42 @@ 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.scheduler.runAfter(0, 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 + 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, + 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", { @@ -143,17 +170,52 @@ 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); 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.scheduler.runAfter(0, 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 + 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, + 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/configuracaoJitsi.ts b/packages/backend/convex/configuracaoJitsi.ts index c04ede5..bd11154 100644 --- a/packages/backend/convex/configuracaoJitsi.ts +++ b/packages/backend/convex/configuracaoJitsi.ts @@ -2,7 +2,6 @@ import { v } from "convex/values"; import { mutation, query, action, internalMutation } from "./_generated/server"; import { registrarAtividade } from "./logsAtividades"; import { api, internal } from "./_generated/api"; -import { encryptSMTPPassword } from "./auth/utils"; /** * Obter configuração de Jitsi ativa @@ -33,44 +32,6 @@ export const obterConfigJitsi = query({ }, }); -/** - * Obter configuração completa de Jitsi (incluindo SSH, mas sem senha) - */ -export const obterConfigJitsiCompleta = query({ - args: { - configId: v.id("configuracaoJitsi"), - }, - handler: async (ctx, args) => { - const config = await ctx.db.get(args.configId); - - if (!config) { - return null; - } - - return { - _id: config._id, - domain: config.domain, - appId: config.appId, - roomPrefix: config.roomPrefix, - useHttps: config.useHttps, - acceptSelfSignedCert: config.acceptSelfSignedCert ?? false, - ativo: config.ativo, - testadoEm: config.testadoEm, - atualizadoEm: config.atualizadoEm, - configuradoEm: config.configuradoEm, - // Configurações SSH (sem senha) - sshHost: config.sshHost, - sshPort: config.sshPort, - sshUsername: config.sshUsername, - sshPasswordHash: config.sshPasswordHash ? "********" : undefined, // Mascarar - sshKeyPath: config.sshKeyPath, - dockerComposePath: config.dockerComposePath, - jitsiConfigPath: config.jitsiConfigPath, - configuradoNoServidor: config.configuradoNoServidor ?? false, - configuradoNoServidorEm: config.configuradoNoServidorEm, - }; - }, -}); /** * Salvar configuração de Jitsi (apenas TI_MASTER) @@ -83,14 +44,6 @@ export const salvarConfigJitsi = mutation({ useHttps: v.boolean(), acceptSelfSignedCert: v.boolean(), configuradoPorId: v.id("usuarios"), - // Opcionais: configurações SSH/Docker - sshHost: v.optional(v.string()), - sshPort: v.optional(v.number()), - sshUsername: v.optional(v.string()), - sshPassword: v.optional(v.string()), // Senha nova (será criptografada) - sshKeyPath: v.optional(v.string()), - dockerComposePath: v.optional(v.string()), - jitsiConfigPath: v.optional(v.string()), }, returns: v.union( v.object({ sucesso: v.literal(true), configId: v.id("configuracaoJitsi") }), @@ -121,12 +74,6 @@ export const salvarConfigJitsi = mutation({ }; } - // Buscar config ativa anterior para manter senha SSH se não fornecida - const configAtiva = await ctx.db - .query("configuracaoJitsi") - .withIndex("by_ativo", (q) => q.eq("ativo", true)) - .first(); - // Desativar config anterior const configsAntigas = await ctx.db .query("configuracaoJitsi") @@ -137,16 +84,6 @@ export const salvarConfigJitsi = mutation({ await ctx.db.patch(config._id, { ativo: false }); } - // Determinar senha SSH: usar nova senha se fornecida, senão manter a atual - let sshPasswordHash: string | undefined = undefined; - if (args.sshPassword && args.sshPassword.trim().length > 0) { - // Nova senha fornecida, criptografar - sshPasswordHash = await encryptSMTPPassword(args.sshPassword); - } else if (configAtiva && configAtiva.sshPasswordHash) { - // Senha não fornecida, manter a atual (já criptografada) - sshPasswordHash = configAtiva.sshPasswordHash; - } - // Criar nova config const configId = await ctx.db.insert("configuracaoJitsi", { domain: args.domain.trim(), @@ -157,14 +94,6 @@ export const salvarConfigJitsi = mutation({ ativo: true, configuradoPor: args.configuradoPorId, atualizadoEm: Date.now(), - // Configurações SSH/Docker - sshHost: args.sshHost?.trim() || undefined, - sshPort: args.sshPort || undefined, - sshUsername: args.sshUsername?.trim() || undefined, - sshPasswordHash: sshPasswordHash, - sshKeyPath: args.sshKeyPath?.trim() || undefined, - dockerComposePath: args.dockerComposePath?.trim() || undefined, - jitsiConfigPath: args.jitsiConfigPath?.trim() || undefined, }); // Log de atividade diff --git a/packages/backend/convex/email.ts b/packages/backend/convex/email.ts index c136376..eaed2b3 100644 --- a/packages/backend/convex/email.ts +++ b/packages/backend/convex/email.ts @@ -1,7 +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 { + renderizarTemplateEmailFromDoc, + type VariaveisTemplate, +} from "./templatesMensagens"; import type { Doc, Id } from "./_generated/dataModel"; // ========== INTERNAL QUERIES ========== @@ -211,22 +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); + + 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: corpoRenderizado, + 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, @@ -397,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 diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index a89e9d0..747fa31 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -80,6 +80,18 @@ 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) { + 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); + } + } + // Buscar time do funcionário const membroTime = await ctx.db .query("timesMembros") @@ -89,15 +101,34 @@ export const listarTodas = query({ .filter((q) => q.eq(q.field("ativo"), true)) .first(); - let time = 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?.funcionarioId) { + // Buscar funcionário do gestor para obter o nome + const gestorFuncionario = await ctx.db.get(gestorUsuario.funcionarioId); + if (gestorFuncionario) { + gestor = { + _id: gestorUsuario._id, + nome: gestorFuncionario.nome, + }; + } + } + } } return { ...ferias, - funcionario, + funcionario: funcionario ? { + ...funcionario, + fotoPerfilUrl, + } : null, time, + gestor, }; }) ); diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts index 4969c56..ff03f7c 100644 --- a/packages/backend/convex/logsLogin.ts +++ b/packages/backend/convex/logsLogin.ts @@ -43,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 @@ -52,6 +59,9 @@ export async function registrarLogin( // Validar e sanitizar IP antes de salvar const ipAddressValidado = validarIP(dados.ipAddress); + + // 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, @@ -63,6 +73,21 @@ export async function registrarLogin( device, browser, sistema, + // 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(), }); @@ -280,6 +305,46 @@ 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()), + 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, { + usuarioId: args.usuarioId, + matriculaOuEmail: args.matriculaOuEmail, + sucesso: args.sucesso, + 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 }; + }, +}); + /** * Lista histórico de logins de um usuário */ @@ -313,7 +378,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; }, }); diff --git a/packages/backend/convex/monitoramento.ts b/packages/backend/convex/monitoramento.ts index a3bb14f..3532ac8 100644 --- a/packages/backend/convex/monitoramento.ts +++ b/packages/backend/convex/monitoramento.ts @@ -1,6 +1,6 @@ import { v } from 'convex/values'; import { mutation, query, internalMutation } from './_generated/server'; -import { internal } from './_generated/api'; +import { internal, api } from './_generated/api'; import { Id } from './_generated/dataModel'; import type { QueryCtx } from './_generated/server'; @@ -363,10 +363,41 @@ 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() + }; + + // Importante: usar api.email.enviarEmailComTemplate (action pública), + // e não internal.email, para corresponder à tipagem gerada em ./_generated/api. + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { + destinatario: email, + destinatarioId: usuario._id, + templateCodigo: 'monitoramento_alerta_sistema', + variaveis: variaveisEmail, + enviadoPor: usuario._id + }); + } + } } } diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 01f8edb..66cb6c8 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -759,24 +759,6 @@ export default defineSchema({ .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'), @@ -807,26 +789,6 @@ export default defineSchema({ .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 @@ -843,30 +805,6 @@ export default defineSchema({ 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 @@ -1791,26 +1729,6 @@ export default defineSchema({ .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')), @@ -1881,5 +1799,104 @@ export default defineSchema({ }) .index('by_pedidoId', ['pedidoId']) .index('by_usuarioId', ['usuarioId']) - .index('by_data', ['data']) + .index('by_data', ['data']), + + // 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" + // 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 (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']) + .index('by_sucesso', ['sucesso']) + .index('by_timestamp', ['timestamp']) + .index('by_ip', ['ipAddress']), + + // 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}} + 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_categoria', ['categoria']), + + // 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) + 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 + 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']), + + // 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']) }); diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index 1b92ecf..78c24de 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 @@ -31,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) */ @@ -40,7 +54,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 +75,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 +94,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 +125,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 +151,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); @@ -188,6 +237,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) */ @@ -396,6 +510,47 @@ 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", + 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 +573,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; +} +