From fdfbd8b051f07d5fad507422118aaca9ac35e054 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 8 Dec 2025 11:52:27 -0300 Subject: [PATCH] feat: implement error handling and logging in server hooks to capture and notify on 404 and 500 errors, enhancing server reliability and monitoring --- apps/web/src/hooks.server.ts | 86 +- .../src/lib/components/chat/ChatWidget.svelte | 171 +- .../registro-pontos/EstatisticasCards.svelte | 104 + .../GraficoEstatisticas.svelte | 218 ++ .../HeaderRegistroPontos.svelte | 66 + apps/web/src/lib/utils/fichaPontoPDF.ts | 436 +++ apps/web/src/lib/utils/ponto/calculos.ts | 349 +++ apps/web/src/lib/utils/ponto/formatacao.ts | 89 + .../lib/utils/ponto/pdf/geradorDetalhesPDF.ts | 1064 ++++++++ .../web/src/lib/utils/ponto/pdf/geradorPDF.ts | 612 +++++ apps/web/src/lib/utils/ponto/processamento.ts | 684 +++++ apps/web/src/lib/utils/ponto/tipos.ts | 111 + apps/web/src/lib/utils/ponto/validacao.ts | 43 + apps/web/src/routes/(dashboard)/+error.svelte | 4 + .../routes/(dashboard)/perfil/+page.svelte | 22 +- .../controle-ponto/banco-horas/+page.svelte | 391 ++- .../recursos-humanos/ferias/+page.svelte | 3 - .../funcionarios/[funcionarioId]/+page.svelte | 2 + .../registro-pontos/+page.svelte | 2381 ++++++++++------- .../recursos-humanos/registro-pontos/+page.ts | 1 + .../src/routes/(dashboard)/ti/+page.svelte | 23 +- .../ti/erros-servidor/+page.svelte | 531 ++++ apps/web/src/routes/+error.svelte | 4 + packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/atestadosLicencas.ts | 131 + packages/backend/convex/errosServidor.ts | 424 +++ packages/backend/convex/ferias.ts | 153 +- packages/backend/convex/pontos.ts | 28 +- .../backend/convex/tables/funcionarios.ts | 4 +- packages/backend/convex/tables/system.ts | 21 +- packages/backend/convex/templatesMensagens.ts | 79 + 31 files changed, 7305 insertions(+), 932 deletions(-) create mode 100644 apps/web/src/lib/components/ponto/registro-pontos/EstatisticasCards.svelte create mode 100644 apps/web/src/lib/components/ponto/registro-pontos/GraficoEstatisticas.svelte create mode 100644 apps/web/src/lib/components/ponto/registro-pontos/HeaderRegistroPontos.svelte create mode 100644 apps/web/src/lib/utils/fichaPontoPDF.ts create mode 100644 apps/web/src/lib/utils/ponto/calculos.ts create mode 100644 apps/web/src/lib/utils/ponto/formatacao.ts create mode 100644 apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts create mode 100644 apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts create mode 100644 apps/web/src/lib/utils/ponto/processamento.ts create mode 100644 apps/web/src/lib/utils/ponto/tipos.ts create mode 100644 apps/web/src/lib/utils/ponto/validacao.ts create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.ts create mode 100644 apps/web/src/routes/(dashboard)/ti/erros-servidor/+page.svelte create mode 100644 packages/backend/convex/errosServidor.ts diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index df2d022..a04f98e 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,9 +1,91 @@ -import type { Handle } from '@sveltejs/kit'; +import type { Handle, HandleServerError } from '@sveltejs/kit'; import { createAuth } from '@sgse-app/backend/convex/auth'; -import { getToken } from '@mmailaender/convex-better-auth-svelte/sveltekit'; +import { getToken, createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit'; +import { api } from '@sgse-app/backend/convex/_generated/api'; export const handle: Handle = async ({ event, resolve }) => { event.locals.token = await getToken(createAuth, event.cookies); return resolve(event); }; + +export const handleError: HandleServerError = async ({ error, event, status, message }) => { + // Notificar erros 404 e 500+ (erros internos do servidor) + if (status === 404 || status === 500 || status >= 500) { + // Evitar loop infinito: não registrar erros relacionados à própria página de erros + const urlPath = event.url.pathname; + if (urlPath.includes('/ti/erros-servidor')) { + console.warn( + `⚠️ Erro na página de erros do servidor (${status}): Não será registrado para evitar loop.` + ); + } else { + try { + // Obter token do usuário (se autenticado) + const token = event.locals.token; + + // Criar cliente Convex para chamar a action + const client = createConvexHttpClient({ + token: token || undefined + }); + + // Extrair informações do erro + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + const url = event.url.toString(); + const method = event.request.method; + const ipAddress = event.getClientAddress(); + const userAgent = event.request.headers.get('user-agent') || undefined; + + // Log para debug + console.log(`📝 Registrando erro ${status} no servidor:`, { + url, + method, + mensagem: errorMessage.substring(0, 100) + }); + + // Chamar action para registrar e notificar erro + // Aguardar a promise mas não bloquear a resposta se falhar + try { + // Usar Promise.race com timeout para evitar bloquear a resposta + const actionPromise = client.action(api.errosServidor.registrarErroServidor, { + statusCode: status, + mensagem: errorMessage, + stack: errorStack, + url, + method, + ipAddress, + userAgent, + usuarioId: undefined // Pode ser implementado depois para obter do token + }); + + // Timeout de 3 segundos + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timeout ao registrar erro')), 3000); + }); + + const resultado = await Promise.race([actionPromise, timeoutPromise]); + console.log(`✅ Erro ${status} registrado com sucesso:`, resultado); + } catch (actionError) { + // Log do erro de notificação, mas não falhar a resposta + console.error( + `❌ Erro ao registrar notificação de erro ${status}:`, + actionError instanceof Error ? actionError.message : actionError + ); + } + } catch (err) { + // Se falhar ao criar cliente ou chamar action, apenas logar + // Não queremos que falhas na notificação quebrem a resposta de erro + console.error( + `❌ Erro ao tentar notificar equipe técnica sobre erro ${status}:`, + err instanceof Error ? err.message : err + ); + } + } + } + + // Retornar mensagem de erro padrão + return { + message: message || 'Erro interno do servidor', + status + }; +}; diff --git a/apps/web/src/lib/components/chat/ChatWidget.svelte b/apps/web/src/lib/components/chat/ChatWidget.svelte index b0f26f6..1a68c18 100644 --- a/apps/web/src/lib/components/chat/ChatWidget.svelte +++ b/apps/web/src/lib/components/chat/ChatWidget.svelte @@ -52,6 +52,12 @@ let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar + + // Suporte a gestos touch (swipe) + let touchStart = $state<{ x: number; y: number; time: number } | null>(null); + let touchCurrent = $state<{ x: number; y: number } | null>(null); + let isTouching = $state(false); + let swipeVelocity = $state(0); // Velocidade do swipe para animação // Tamanho da janela (redimensionável) const MIN_WIDTH = 300; @@ -613,6 +619,134 @@ // Não prevenir default para permitir clique funcionar se não houver movimento } + // Handlers para gestos touch (swipe) + function handleTouchStart(e: TouchEvent) { + if (!position || e.touches.length !== 1) return; + const touch = e.touches[0]; + touchStart = { + x: touch.clientX, + y: touch.clientY, + time: Date.now() + }; + touchCurrent = { x: touch.clientX, y: touch.clientY }; + isTouching = true; + isDragging = true; + dragStart = { + x: touch.clientX - position.x, + y: touch.clientY - position.y + }; + hasMoved = false; + shouldPreventClick = false; + document.body.classList.add('dragging'); + } + + function handleTouchMove(e: TouchEvent) { + if (!isTouching || !touchStart || !position || e.touches.length !== 1) return; + const touch = e.touches[0]; + touchCurrent = { x: touch.clientX, y: touch.clientY }; + + // Calcular velocidade do swipe + const deltaTime = Date.now() - touchStart.time; + const deltaX = touch.clientX - touchStart.x; + const deltaY = touch.clientY - touchStart.y; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (deltaTime > 0) { + swipeVelocity = distance / deltaTime; // pixels por ms + } + + // Calcular nova posição + const newX = touch.clientX - dragStart.x; + const newY = touch.clientY - dragStart.y; + + // Verificar se houve movimento significativo + const deltaXAbs = Math.abs(newX - position.x); + const deltaYAbs = Math.abs(newY - position.y); + + if (deltaXAbs > dragThreshold || deltaYAbs > dragThreshold) { + hasMoved = true; + shouldPreventClick = true; + } + + // Dimensões do widget + const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72; + const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72; + + const winWidth = + windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0); + const winHeight = + windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0); + + const minX = -(widgetWidth - 100); + const maxX = Math.max(0, winWidth - 100); + const minY = -(widgetHeight - 100); + const maxY = Math.max(0, winHeight - 100); + + position = { + x: Math.max(minX, Math.min(newX, maxX)), + y: Math.max(minY, Math.min(newY, maxY)) + }; + } + + function handleTouchEnd(e: TouchEvent) { + if (!isTouching || !touchStart || !position) return; + + const hadMoved = hasMoved; + + // Aplicar momentum se houver velocidade suficiente + if (swipeVelocity > 0.5 && hadMoved) { + const deltaX = touchCurrent ? touchCurrent.x - touchStart.x : 0; + const deltaY = touchCurrent ? touchCurrent.y - touchStart.y : 0; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance > 10) { + // Aplicar momentum suave + const momentum = Math.min(swipeVelocity * 50, 200); // Limitar momentum + const angle = Math.atan2(deltaY, deltaX); + + let momentumX = position.x + Math.cos(angle) * momentum; + let momentumY = position.y + Math.sin(angle) * momentum; + + // Limitar dentro dos bounds + const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72; + const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72; + const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0); + const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0); + const minX = -(widgetWidth - 100); + const maxX = Math.max(0, winWidth - 100); + const minY = -(widgetHeight - 100); + const maxY = Math.max(0, winHeight - 100); + + momentumX = Math.max(minX, Math.min(momentumX, maxX)); + momentumY = Math.max(minY, Math.min(momentumY, maxY)); + + position = { x: momentumX, y: momentumY }; + isAnimating = true; + + setTimeout(() => { + isAnimating = false; + ajustarPosicao(); + }, 300); + } + } else { + ajustarPosicao(); + } + + isDragging = false; + isTouching = false; + touchStart = null; + touchCurrent = null; + swipeVelocity = 0; + document.body.classList.remove('dragging'); + + setTimeout(() => { + hasMoved = false; + shouldPreventClick = false; + }, 100); + + savePosition(); + } + function handleMouseMove(e: MouseEvent) { if (isResizing) { handleResizeMove(e); @@ -747,10 +881,14 @@ window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('touchmove', handleTouchMove, { passive: false }); + window.addEventListener('touchend', handleTouchEnd); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleTouchEnd); }; }); @@ -789,9 +927,10 @@ onmouseup={(e) => { handleMouseUp(e); }} + ontouchstart={handleTouchStart} onclick={(e) => { // Só executar toggle se não houve movimento durante o arrastar - if (!shouldPreventClick && !hasMoved) { + if (!shouldPreventClick && !hasMoved && !isTouching) { handleToggle(); } else { // Prevenir clique se houve movimento @@ -802,11 +941,23 @@ }} aria-label="Abrir chat" > - +
+ +
+ + {#if isDragging || isTouching} +
+ {/if} + import { BarChart3, CheckCircle2, XCircle, Users } from 'lucide-svelte'; + + interface Estatisticas { + totalRegistros: number; + dentroDoPrazo: number; + foraDoPrazo: number; + totalFuncionarios: number; + funcionariosDentroPrazo: number; + funcionariosForaPrazo: number; + } + + interface Props { + estatisticas?: Estatisticas; + } + + let { estatisticas = undefined }: Props = $props(); + + +{#if estatisticas} +
+ +
+
+
+
+

Total de Registros

+

{estatisticas.totalRegistros}

+
+
+ +
+
+
+
+ + +
+
+
+
+

Dentro do Prazo

+

{estatisticas.dentroDoPrazo}

+

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

+
+
+ +
+
+
+
+ + +
+
+
+
+

Fora do Prazo

+

{estatisticas.foraDoPrazo}

+

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

+
+
+ +
+
+
+
+ + +
+
+
+
+

Funcionários

+

{estatisticas.totalFuncionarios}

+

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

+
+
+ +
+
+
+
+
+{/if} + diff --git a/apps/web/src/lib/components/ponto/registro-pontos/GraficoEstatisticas.svelte b/apps/web/src/lib/components/ponto/registro-pontos/GraficoEstatisticas.svelte new file mode 100644 index 0000000..460ac03 --- /dev/null +++ b/apps/web/src/lib/components/ponto/registro-pontos/GraficoEstatisticas.svelte @@ -0,0 +1,218 @@ + + +
+
+
+

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

+
+
+ {#if isLoading} +
+
+ + Carregando estatísticas... +
+
+ {:else if error} +
+
+ +
+

Erro ao carregar estatísticas

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

Nenhuma estatística disponível

+
+
+ {:else} + + {/if} +
+
+
+ diff --git a/apps/web/src/lib/components/ponto/registro-pontos/HeaderRegistroPontos.svelte b/apps/web/src/lib/components/ponto/registro-pontos/HeaderRegistroPontos.svelte new file mode 100644 index 0000000..0007918 --- /dev/null +++ b/apps/web/src/lib/components/ponto/registro-pontos/HeaderRegistroPontos.svelte @@ -0,0 +1,66 @@ + + +
+
+
+
+
+
+ +
+
+

+ Registro de Pontos +

+

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

+
+
+ {#if estatisticas} +
+
+

Total de Registros

+

{estatisticas.totalRegistros}

+
+
+

Funcionários

+

{estatisticas.totalFuncionarios}

+
+
+
+ + {estatisticas.totalRegistros > 0 + ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% dentro do prazo + + Ativo +
+
+ {/if} +
+
+ diff --git a/apps/web/src/lib/utils/fichaPontoPDF.ts b/apps/web/src/lib/utils/fichaPontoPDF.ts new file mode 100644 index 0000000..99f5d2f --- /dev/null +++ b/apps/web/src/lib/utils/fichaPontoPDF.ts @@ -0,0 +1,436 @@ +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; +import { formatarHoraPonto, formatarDataDDMMAAAA, getTipoRegistroLabel } from './ponto'; + +// Tipos e interfaces +export type TipoDia = 'normal' | 'atestado' | 'ausencia' | 'licenca' | 'abonado' | 'nao_computado' | 'ferias' | 'inconsistente'; + +export interface SaldoDiario { + diferencaMinutos: number; + trabalhadoMinutos: number; + esperadoMinutos: number; +} + +export interface RegistroPonto { + _id: Id<'registrosPonto'>; + tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'; + data: string; + hora: number; + minuto: number; + timestamp: number; + dentroDoPrazo: boolean; +} + +export interface DiaFichaPonto { + data: string; + dataFormatada: string; + tipoDia: TipoDia; + registros: RegistroPonto[]; + registrosEsperados: Array<{ tipo: string; hora: number; minuto: number; data: string }>; + saldoDiario: SaldoDiario | null; + saldoAcumulado: number; + atestado: { + _id: Id<'atestados'>; + tipo: string; + dataInicio: string; + dataFim: string; + motivo?: string; + } | null; + ausencia: { + _id: Id<'solicitacoesAusencias'>; + motivo: string; + dataInicio: string; + dataFim: string; + status: string; + } | null; + licenca: { + _id: Id<'licencas'>; + tipo: string; + dataInicio: string; + dataFim: string; + } | null; + ajustes: Array<{ + _id: Id<'ajustesBancoHoras'>; + tipo: 'abonar' | 'descontar' | 'compensar'; + valorMinutos: number; + motivoDescricao?: string; + gestorId?: Id<'usuarios'>; + }>; + inconsistencias: Array<{ + _id: Id<'inconsistenciasBancoHoras'>; + tipo: string; + descricao: string; + dataDetectada: string; + status: 'pendente' | 'resolvida' | 'ignorada'; + resolvidoPor?: Id<'usuarios'>; + resolvidoEm?: number; + }>; + homologacoes: Array<{ + _id: Id<'homologacoesPonto'>; + motivoDescricao?: string; + gestorId: Id<'usuarios'>; + }>; + dispensa: { + _id: Id<'dispensasRegistro'>; + motivo: string; + dataInicio: string; + dataFim: string; + ativo: boolean; + } | null; + computado: boolean; +} + +export interface ResumoPeriodo { + totalDias: number; + diasTrabalhados: number; + diasComAtestado: number; + diasAusentes: number; + diasComLicenca: number; + diasAbonados: number; + diasNaoComputados: number; + diasComInconsistencia: number; + totalHorasTrabalhadas: number; + totalHorasEsperadas: number; + diferencaTotal: number; + saldoInicial: number; + saldoFinal: number; + saldoPeriodo: number; + totalInconsistencias: number; + saldoInicialFormatado?: string; + saldoPeriodoFormatado?: string; + saldoFinalFormatado?: string; + totalHorasTrabalhadasFormatado?: string; + totalHorasEsperadasFormatado?: string; + diferencaTotalFormatado?: string; +} + +export interface SectionsPDF { + dadosFuncionario: boolean; + registrosPonto: boolean; + saldoDiario: boolean; + bancoHoras: boolean; + alteracoesGestor: boolean; + dispensasRegistro: boolean; +} + +export interface FuncionarioPDF { + _id: Id<'funcionarios'>; + nome: string; + matricula?: string; + descricaoCargo?: string; +} + +export interface ConfigPontoPDF { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + nomeEntrada?: string; + nomeSaidaAlmoco?: string; + nomeRetornoAlmoco?: string; + nomeSaida?: string; +} + +type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; +}; + +// Função auxiliar para adicionar logo +export async function adicionarLogo(doc: jsPDF, logoGovPE: string): Promise { + 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 = 25; + const aspectRatio = logoImg.height / logoImg.width; + const logoHeight = logoWidth * aspectRatio; + + doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight); + yPosition = Math.max(20, 10 + logoHeight / 2); + } catch (err) { + console.warn('Não foi possível carregar a logo:', err); + } + return yPosition; +} + +// Função auxiliar para adicionar cabeçalho +export function adicionarCabecalho(doc: jsPDF, yPosition: number): number { + doc.setFontSize(16); + doc.setTextColor(41, 128, 185); + doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' }); + return yPosition + 10; +} + +// Função auxiliar para verificar se precisa de nova página +export function verificarNovaPagina(doc: jsPDF, yPosition: number, limite: number = 250): number { + if (yPosition > limite) { + doc.addPage(); + return 20; + } + return yPosition; +} + +// Função auxiliar para adicionar dados do funcionário +export function adicionarDadosFuncionario( + doc: jsPDF, + yPosition: number, + funcionario: FuncionarioPDF, + dataInicio: string, + dataFim: string +): number { + 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); + + if (funcionario.matricula) { + doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition); + yPosition += 6; + } + doc.text(`Nome: ${funcionario.nome}`, 15, yPosition); + yPosition += 6; + if (funcionario.descricaoCargo) { + doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition); + yPosition += 6; + } + + yPosition += 5; + const periodoFormatado = `${formatarDataDDMMAAAA(dataInicio)} a ${formatarDataDDMMAAAA(dataFim)}`; + doc.text(`Período: ${periodoFormatado}`, 15, yPosition); + yPosition += 10; + + return yPosition; +} + +// Função auxiliar para formatar horas e minutos +export function formatarHorasMinutos(minutos: number): string { + const horas = Math.floor(Math.abs(minutos) / 60); + const mins = Math.abs(minutos) % 60; + const sinal = minutos >= 0 ? '+' : '-'; + return `${sinal}${horas}h ${mins}min`; +} + +// Função auxiliar para adicionar resumo do período +export function adicionarResumoPeriodo( + doc: jsPDF, + yPosition: number, + resumo: ResumoPeriodo, + formatarHoras: (minutos: number) => string, + formatarMinutos: (minutos: number) => string +): number { + yPosition = verificarNovaPagina(doc, yPosition); + + doc.setFontSize(14); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('RESUMO DO PERÍODO', 15, yPosition); + yPosition += 10; + + const resumoData: Array<[string, string]> = [ + ['Total de Dias', resumo.totalDias.toString()], + ['Dias Trabalhados', resumo.diasTrabalhados.toString()], + ['Dias com Atestado', resumo.diasComAtestado.toString()], + ['Dias Ausentes', resumo.diasAusentes.toString()], + ['Dias com Licença', resumo.diasComLicenca.toString()], + ['Dias Abonados', resumo.diasAbonados.toString()], + ['Dias Não Computados', resumo.diasNaoComputados.toString()], + ['Dias com Inconsistência', resumo.diasComInconsistencia.toString()], + ['Total de Inconsistências', resumo.totalInconsistencias.toString()], + ['Total de Horas Trabalhadas', resumo.totalHorasTrabalhadasFormatado || formatarHoras(resumo.totalHorasTrabalhadas)], + ['Total de Horas Esperadas', resumo.totalHorasEsperadasFormatado || formatarHoras(resumo.totalHorasEsperadas)], + ['Diferença Total', resumo.diferencaTotalFormatado || formatarMinutos(resumo.diferencaTotal)] + ]; + + autoTable(doc, { + startY: yPosition, + head: [['Item', 'Valor']], + body: resumoData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 9 + }, + columnStyles: { + 0: { cellWidth: 100, fontStyle: 'bold' }, + 1: { cellWidth: 90 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 3 }, + didParseCell: (data) => { + if (data.section === 'body' && data.column.index === 1) { + const valor = data.cell.text[0] as string; + if (valor.startsWith('+')) { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } else if (valor.startsWith('-')) { + data.cell.styles.textColor = [200, 0, 0]; + data.cell.styles.fontStyle = 'bold'; + } + } + } + }); + + const finalYResumo = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + return finalYResumo + 10; +} + +// Função auxiliar para adicionar saldos do período +export function adicionarSaldosPeriodo( + doc: jsPDF, + yPosition: number, + resumo: ResumoPeriodo, + formatarMinutos: (minutos: number) => string +): number { + yPosition = verificarNovaPagina(doc, yPosition); + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('SALDOS DO PERÍODO', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const saldosData: Array<[string, string]> = [ + ['Saldo Inicial', resumo.saldoInicialFormatado || formatarMinutos(resumo.saldoInicial)], + ['Saldo do Período', resumo.saldoPeriodoFormatado || formatarMinutos(resumo.saldoPeriodo)], + ['Saldo Final', resumo.saldoFinalFormatado || formatarMinutos(resumo.saldoFinal)] + ]; + + autoTable(doc, { + startY: yPosition, + head: [['Tipo', 'Valor']], + body: saldosData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: 255, + fontStyle: 'bold' + }, + styles: { + fontSize: 10, + cellPadding: 4 + }, + columnStyles: { + 0: { cellWidth: 120, fontStyle: 'bold' }, + 1: { cellWidth: 60, halign: 'right' } + }, + didParseCell: (data) => { + if (data.section === 'body' && data.column.index === 1) { + const valor = data.cell.text[0] as string; + const linhaIndex = data.row.index; + let saldoMinutos = 0; + + if (linhaIndex === 0) { + saldoMinutos = resumo.saldoInicial; + } else if (linhaIndex === 1) { + saldoMinutos = resumo.saldoPeriodo; + } else if (linhaIndex === 2) { + saldoMinutos = resumo.saldoFinal; + } + + if (saldoMinutos > 0) { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } else if (saldoMinutos < 0) { + data.cell.styles.textColor = [200, 0, 0]; + data.cell.styles.fontStyle = 'bold'; + } else { + data.cell.styles.textColor = [0, 0, 0]; + } + } + } + }); + + const finalYSaldos = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + return finalYSaldos + 10; +} + +// Função auxiliar para adicionar legenda +export function adicionarLegenda(doc: jsPDF, yPosition: number): number { + yPosition = verificarNovaPagina(doc, yPosition); + + doc.setFontSize(14); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('LEGENDA', 15, yPosition); + yPosition += 10; + + const legendaData: Array<[string, string]> = [ + ['Cor de Fundo - Branco', 'Dia normal'], + ['Cor de Fundo - Azul Claro', 'Dia com atestado médico'], + ['Cor de Fundo - Amarelo Claro', 'Dia com ausência aprovada'], + ['Cor de Fundo - Verde Claro', 'Dia abonado'], + ['Cor de Fundo - Cinza Claro', 'Dia não computado (dispensa/férias)'], + ['Cor de Fundo - Laranja Claro', 'Dia com inconsistência'], + ['Texto Verde', 'Saldo positivo / Registro marcado'], + ['Texto Vermelho', 'Saldo negativo / Registro não marcado'], + ['✓', 'Registro marcado'], + ['✗', 'Registro não marcado'], + ['⚠', 'Inconsistência detectada'], + ['🏥', 'Atestado médico'], + ['🚫', 'Ausência'], + ['📋', 'Licença'], + ['✅', 'Abonado'], + ['⏸', 'Não computado'] + ]; + + autoTable(doc, { + startY: yPosition, + head: [['Símbolo/Cor', 'Significado']], + body: legendaData, + theme: 'striped', + headStyles: { + fillColor: [60, 60, 60], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 9 + }, + columnStyles: { + 0: { cellWidth: 80, fontStyle: 'bold' }, + 1: { cellWidth: 110 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 3 } + }); + + const finalYLegenda = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + return finalYLegenda + 10; +} + +// Função auxiliar para adicionar rodapé +export function adicionarRodape(doc: jsPDF): void { + 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' } + ); + } +} + + diff --git a/apps/web/src/lib/utils/ponto/calculos.ts b/apps/web/src/lib/utils/ponto/calculos.ts new file mode 100644 index 0000000..c0ef229 --- /dev/null +++ b/apps/web/src/lib/utils/ponto/calculos.ts @@ -0,0 +1,349 @@ +import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; + +/** + * Calcula saldos parciais entre cada par entrada/saída + * Retorna um mapa com o índice do registro e seu saldo parcial + */ +export function calcularSaldosParciais( + registros: Array<{ tipo: string; hora: number; minuto: number; _id?: Id<'registrosPonto'> }> +): 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) { + return a.hora - b.hora; + } + return a.minuto - b.minuto; + }); + + // Identificar pares entrada/saída + // Par 1: entrada -> saida_almoco + // Par 2: retorno_almoco -> saida + 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; + } else if (entradaAtual) { + // Qualquer saída (saida_almoco ou saida) fecha o período atual + if (registro.tipo === 'saida_almoco' || registro.tipo === 'saida') { + // 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 + } + + const horas = Math.floor(saldoMinutos / 60); + const minutos = saldoMinutos % 60; + + // Salvar saldo no índice original do registro de saída + saldos.set(registro.originalIndex, { + saldoMinutos, + horas, + minutos, + positivo: true, + parNumero + }); + + entradaAtual = null; // Resetar para próximo par + parNumero++; + } + } + } + + return saldos; +} + +/** + * Calcula saldo diário simples (entrada até saída) + */ +export 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 + const registrosOrdenados = [...registros].sort((a, b) => { + if (a.hora !== b.hora) { + return a.hora - b.hora; + } + return a.minuto - b.minuto; + }); + + // Buscar entrada (primeiro registro do tipo 'entrada') + const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); + // Buscar saída (último registro do tipo 'saida') + const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop(); + + if (!entrada || !saida) return null; + + // 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) { + saldoMinutos += 24 * 60; // Adicionar um dia em minutos + } + + const horas = Math.floor(saldoMinutos / 60); + const minutos = saldoMinutos % 60; + + return { + saldoMinutos, + horas, + minutos, + positivo: true // Sempre positivo, pois é tempo trabalhado + }; +} + +/** + * Calcula saldos por par entrada/saída + * Retorna um mapa com o índice do registro e informações do saldo do par + */ +export 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 + const registrosOrdenados = [...registros].sort((a, b) => { + if (a.hora !== b.hora) { + return a.hora - b.hora; + } + return a.minuto - b.minuto; + }); + + let parIndex = 0; + let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null; + let indicesPar: number[] = []; + + 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 + if (entradaAtual && indicesPar.length > 0) { + indicesPar = []; + } + entradaAtual = { ...reg, index: i }; + indicesPar = [i]; + } + // 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 + } + + const horas = Math.floor(saldoMinutos / 60); + const minutos = saldoMinutos % 60; + + // Associar saldo a todos os registros do par + for (const idx of indicesPar) { + saldos.set(idx, { + saldoMinutos, + horas, + minutos, + parIndex, + tamanhoPar: indicesPar.length + }); + } + + parIndex++; + entradaAtual = null; + indicesPar = []; + } + } + + return saldos; +} + +/** + * Calcula saldos comparativos por par entrada/saída + * Compara horários reais com horários esperados configurados + * Retorna mapa com saldo trabalhado, esperado e diferença + */ +export function calcularSaldoComparativoPorPar( + registros: Array<{ tipo: string; hora: number; minuto: number }>, + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + } +): Map< + number, + { + trabalhadoMinutos: number; + trabalhadoHoras: number; + trabalhadoMinutosResto: number; + esperadoMinutos: number; + esperadoHoras: number; + esperadoMinutosResto: number; + diferencaMinutos: number; + diferencaHoras: number; + diferencaMinutosResto: number; + parIndex: number; + tamanhoPar: number; + } +> { + 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 [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number); + + // Ordenar registros por hora e minuto + const registrosOrdenados = [...registros].sort((a, b) => { + if (a.hora !== b.hora) { + return a.hora - b.hora; + } + return a.minuto - b.minuto; + }); + + let parIndex = 0; + let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null; + let indicesPar: number[] = []; + + 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 + if (entradaAtual && indicesPar.length > 0) { + indicesPar = []; + } + entradaAtual = { ...reg, index: i }; + indicesPar = [i]; + } + // Identificar fim de um par (saida_almoco ou saida) + else if ((reg.tipo === 'saida_almoco' || reg.tipo === 'saida') && entradaAtual) { + indicesPar.push(i); + + // Calcular tempo trabalhado real (saída - entrada) + const minutosEntradaReal = entradaAtual.hora * 60 + entradaAtual.minuto; + const minutosSaidaReal = reg.hora * 60 + reg.minuto; + let trabalhadoMinutos = minutosSaidaReal - minutosEntradaReal; + if (trabalhadoMinutos < 0) { + trabalhadoMinutos += 24 * 60; + } + + // Calcular tempo esperado baseado no tipo de par + let esperadoMinutos: number; + if (entradaAtual.tipo === 'entrada') { + // Par 1: entrada -> saida_almoco + const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado; + const minutosSaidaEsperada = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado; + esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada; + if (esperadoMinutos < 0) { + esperadoMinutos += 24 * 60; + } + } else { + // Par 2: retorno_almoco -> saida + const minutosEntradaEsperada = + horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; + const minutosSaidaEsperada = horaSaidaEsperada * 60 + minutoSaidaEsperado; + esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada; + if (esperadoMinutos < 0) { + esperadoMinutos += 24 * 60; + } + } + + // Calcular diferença (trabalhado - esperado) + const diferencaMinutos = trabalhadoMinutos - esperadoMinutos; + + // Converter para horas e minutos + const trabalhadoHoras = Math.floor(trabalhadoMinutos / 60); + const trabalhadoMinutosResto = trabalhadoMinutos % 60; + + const esperadoHoras = Math.floor(esperadoMinutos / 60); + const esperadoMinutosResto = esperadoMinutos % 60; + + const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60); + const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60; + + // Associar saldo a todos os registros do par + for (const idx of indicesPar) { + saldos.set(idx, { + trabalhadoMinutos, + trabalhadoHoras, + trabalhadoMinutosResto, + esperadoMinutos, + esperadoHoras, + esperadoMinutosResto, + diferencaMinutos, + diferencaHoras, + diferencaMinutosResto, + parIndex, + tamanhoPar: indicesPar.length + }); + } + + parIndex++; + entradaAtual = null; + indicesPar = []; + } + } + + return saldos; +} + diff --git a/apps/web/src/lib/utils/ponto/formatacao.ts b/apps/web/src/lib/utils/ponto/formatacao.ts new file mode 100644 index 0000000..68d2f16 --- /dev/null +++ b/apps/web/src/lib/utils/ponto/formatacao.ts @@ -0,0 +1,89 @@ +/** + * Funções de formatação para dados de ponto + */ + +/** + * Converte data de yyyy-mm-dd para dd/mm/yyyy + */ +export function formatarDataParaExibicao(data: string): string { + if (!data) return ''; + const [ano, mes, dia] = data.split('-'); + return `${dia}/${mes}/${ano}`; +} + +/** + * Converte data de dd/mm/yyyy para yyyy-mm-dd + */ +export function formatarDataParaBackend(data: string, onlyDigits: (str: string) => string, validateDate: (str: string) => boolean): string { + if (!data) return ''; + const apenasDigitos = onlyDigits(data); + if (apenasDigitos.length !== 8) return data; + + const dia = apenasDigitos.slice(0, 2); + const mes = apenasDigitos.slice(2, 4); + const ano = apenasDigitos.slice(4, 8); + + // Validar se a data é válida + if (!validateDate(`${dia}/${mes}/${ano}`)) { + return data; // Retornar valor original se inválido + } + + return `${ano}-${mes}-${dia}`; +} + +/** + * Formata saldo de horas em minutos para string legível + */ +export function formatarSaldoHoras(minutos: number): string { + const horas = Math.floor(Math.abs(minutos) / 60); + const mins = Math.abs(minutos) % 60; + const sinal = minutos >= 0 ? '+' : '-'; + return `${sinal}${horas}h ${mins}min`; +} + +/** + * Formata saldo diário + */ +export 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`; +} + +/** + * Formata minutos para string HH:MM + */ +export function formatarMinutos(minutos: number): string { + const absMinutos = Math.abs(minutos); + const horas = Math.floor(absMinutos / 60); + const mins = absMinutos % 60; + const sinal = minutos >= 0 ? '+' : '-'; + return `${sinal}${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +/** + * Formata minutos para string de horas + */ +export function formatarHoras(minutos: number): string { + const absMinutos = Math.abs(minutos); + const horas = Math.floor(absMinutos / 60); + const mins = absMinutos % 60; + const sinal = minutos >= 0 ? '+' : '-'; + return `${sinal}${horas}h ${mins}min`; +} + +/** + * Obtém o nome do dia da semana + */ +export function obterDiaSemana(data: string): string { + const [ano, mes, dia] = data.split('-').map(Number); + const date = new Date(ano, mes - 1, dia); + const dias = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + return dias[date.getDay()] || ''; +} + diff --git a/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts b/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts new file mode 100644 index 0000000..3318b34 --- /dev/null +++ b/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts @@ -0,0 +1,1064 @@ +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; +import type { ConvexClient } from 'convex-svelte'; +import { getTipoRegistroLabel } from '../../ponto'; +import { api } from '@sgse-app/backend/convex/_generated/api'; + +/** + * Gera PDF com detalhes de um registro de ponto + */ +export async function imprimirDetalhesRegistro( + client: ConvexClient, + registroId: Id<'registrosPonto'>, + logoGovPE: string, + onError: (message: string) => void +): Promise { + try { + // Buscar dados completos do registro + const registro = await client.query(api.pontos.obterRegistro, { registroId }); + + if (!registro) { + onError('Registro não encontrado'); + return; + } + + 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); + } + + // Cabeçalho + doc.setFontSize(18); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, { align: 'center' }); + + doc.setDrawColor(41, 128, 185); + doc.setLineWidth(0.5); + doc.line(15, yPosition + 3, 195, yPosition + 3); + + yPosition += 15; + + // Informações do Funcionário + const funcionarioData: Array<[string, string]> = []; + if (registro.funcionario) { + if (registro.funcionario.matricula) { + funcionarioData.push(['Matrícula', registro.funcionario.matricula]); + } + funcionarioData.push(['Nome', registro.funcionario.nome]); + if (registro.funcionario.descricaoCargo) { + funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]); + } + } + + if (funcionarioData.length > 0) { + doc.setFontSize(12); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: funcionarioData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10, + textColor: [0, 0, 0] + }, + columnStyles: { + 0: { cellWidth: 50, fontStyle: 'bold' }, + 1: { cellWidth: 140 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 10; + } + + // Informações do Registro + const config = await client.query(api.configuracaoPonto.obterConfiguracao, {}); + const tipoLabel = config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + 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: Array<[string, 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)'] + ]; + + if (registro.justificativa) { + registroData.push(['Justificativa', registro.justificativa]); + } + + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('DADOS DO REGISTRO', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: registroData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10, + textColor: [0, 0, 0] + }, + columnStyles: { + 0: { cellWidth: 50, fontStyle: 'bold' }, + 1: { cellWidth: 140 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 1) { + if (data.cell.text[0] === 'Dentro do Prazo') { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } else if (data.cell.text[0] === 'Fora do Prazo') { + data.cell.styles.textColor = [200, 0, 0]; + data.cell.styles.fontStyle = 'bold'; + } + } + } + }); + + type JsPDFWithAutoTable2 = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalYRegistro = (doc as JsPDFWithAutoTable2).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalYRegistro + 10; + + // Localização + if (registro.latitude && registro.longitude) { + yPosition = adicionarSecaoLocalizacaoPDF(doc, yPosition, registro); + } + + // Validação GPS + if (registro.latitude && registro.longitude) { + yPosition = await adicionarSecaoValidacaoGPSPDF(doc, yPosition, registro, client); + } + + // Geofencing + if (registro.latitude && registro.longitude) { + yPosition = await adicionarSecaoGeofencingPDF(doc, yPosition, registro, client); + } + + // Dados Técnicos + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + yPosition = adicionarSecaoDadosTecnicosPDF(doc, yPosition, registro); + + // Imagem + if (registro.imagemUrl) { + yPosition = await adicionarImagemPDF(doc, yPosition, registro.imagemUrl); + } + + // Rodapé + adicionarRodapeDetalhesPDF(doc); + + // Salvar + const nomeArquivo = `detalhes-ponto-${registro.data}-${registro.hora.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`; + doc.save(nomeArquivo); + } catch (error) { + console.error('Erro ao gerar PDF detalhado:', error); + onError('Erro ao gerar relatório detalhado. Tente novamente.'); + } +} + +/** + * Adiciona seção de localização no PDF + */ +function adicionarSecaoLocalizacaoPDF( + doc: jsPDF, + yPosition: number, + registro: any +): number { + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + + const localizacaoData: Array<[string, string]> = [ + ['Latitude', registro.latitude.toFixed(6)], + ['Longitude', registro.longitude.toFixed(6)] + ]; + + if (registro.precisao) { + localizacaoData.push(['Precisão', `${registro.precisao.toFixed(2)} metros`]); + } + if (registro.endereco) { + localizacaoData.push(['Endereço', registro.endereco]); + } + if (registro.cidade) { + localizacaoData.push(['Cidade', registro.cidade]); + } + if (registro.estado) { + localizacaoData.push(['Estado', registro.estado]); + } + if (registro.pais) { + localizacaoData.push(['País', registro.pais]); + } + if (registro.timezone) { + localizacaoData.push(['Fuso Horário', registro.timezone]); + } + + doc.setFontSize(12); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('LOCALIZAÇÃO', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: localizacaoData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10, + textColor: [0, 0, 0] + }, + columnStyles: { + 0: { cellWidth: 50, fontStyle: 'bold' }, + 1: { cellWidth: 140 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + return finalY + 10; +} + +/** + * Adiciona seção de validação GPS no PDF + */ +async function adicionarSecaoValidacaoGPSPDF( + doc: jsPDF, + yPosition: number, + registro: any, + client: ConvexClient +): Promise { + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('VALIDAÇÃO DE LOCALIZAÇÃO GPS', 15, yPosition); + yPosition += 10; + + // Dados do GPS + const gpsData: Array<[string, string]> = []; + if (registro.precisao !== null && registro.precisao !== undefined) { + gpsData.push(['Precisão', `${registro.precisao.toFixed(2)} metros`]); + } + + if (gpsData.length > 0) { + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: gpsData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10, + textColor: [0, 0, 0] + }, + columnStyles: { + 0: { cellWidth: 50, fontStyle: 'bold' }, + 1: { cellWidth: 140 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 5; + } + + // Confiabilidade + const confiabilidadeData: Array<[string, string]> = []; + if (registro.confiabilidadeGPS !== null && registro.confiabilidadeGPS !== undefined) { + const confiabilidadePercent = (registro.confiabilidadeGPS * 100).toFixed(1); + confiabilidadeData.push(['Confiabilidade GPS (Frontend)', `${confiabilidadePercent}%`]); + } + if ( + registro.scoreConfiancaBackend !== null && + registro.scoreConfiancaBackend !== undefined + ) { + const scorePercent = (registro.scoreConfiancaBackend * 100).toFixed(1); + confiabilidadeData.push(['Score de Confiança (Backend)', `${scorePercent}%`]); + } + + if (confiabilidadeData.length > 0) { + doc.setFontSize(11); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'bold'); + doc.text('Confiabilidade:', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: confiabilidadeData, + theme: 'striped', + headStyles: { + fillColor: [60, 60, 60], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10 + }, + columnStyles: { + 0: { cellWidth: 80, fontStyle: 'bold' }, + 1: { cellWidth: 110 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 1) { + const valorTexto = data.cell.text[0]; + const valorNum = parseFloat(valorTexto.replace('%', '')); + if (!isNaN(valorNum)) { + if (valorNum >= 70) { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } else if (valorNum >= 40) { + data.cell.styles.textColor = [255, 165, 0]; + data.cell.styles.fontStyle = 'bold'; + } else { + data.cell.styles.textColor = [255, 0, 0]; + data.cell.styles.fontStyle = 'bold'; + } + } + } + } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 5; + } + + // Status de Validação + if (registro.suspeitaSpoofing !== null && registro.suspeitaSpoofing !== undefined) { + const statusData: Array<[string, string]> = []; + + if (registro.suspeitaSpoofing) { + statusData.push(['Status', '⚠️ MARCAÇÃO SUSPEITA DETECTADA']); + if (registro.motivoSuspeita) { + statusData.push(['Motivo', registro.motivoSuspeita]); + } + } else { + statusData.push(['Status', '✓ Localização validada com sucesso']); + } + + if (statusData.length > 0) { + doc.setFontSize(11); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'bold'); + doc.text('Status de Validação:', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: statusData, + theme: 'striped', + headStyles: { + fillColor: registro.suspeitaSpoofing ? [200, 0, 0] : [0, 128, 0], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10 + }, + columnStyles: { + 0: { cellWidth: 50, fontStyle: 'bold' }, + 1: { cellWidth: 140 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 1) { + if (registro.suspeitaSpoofing) { + data.cell.styles.textColor = [200, 0, 0]; + data.cell.styles.fontStyle = 'bold'; + } else { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } + } + } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 5; + } + } + + // Avisos + 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'); + doc.text('Avisos de Validação:', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['', 'Aviso']], + body: avisosData, + theme: 'striped', + headStyles: { + fillColor: [255, 165, 0], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10, + textColor: [0, 0, 0] + }, + columnStyles: { + 0: { cellWidth: 10 }, + 1: { cellWidth: 180 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 5; + } + + // Análise de Propriedades GPS + const propriedadesData: Array<[string, string]> = []; + let propriedadesGPS = 0; + const propriedadesTotais = 5; + + 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 + ) { + 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) + ) { + propriedadesData.push(['Direção (Heading)', '✓ Disponível']); + propriedadesGPS++; + } else { + propriedadesData.push(['Direção (Heading)', '✗ Não disponível']); + } + + if (registro.speed !== null && registro.speed !== undefined && !isNaN(registro.speed)) { + propriedadesData.push(['Velocidade', '✓ Disponível']); + propriedadesGPS++; + } else { + propriedadesData.push(['Velocidade', '✗ Não disponível']); + } + + 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 + ) { + propriedadesData.push(['Precisão GPS', '⚠ Precisão média (20-100m)']); + propriedadesGPS += 0.5; + } else { + propriedadesData.push(['Precisão GPS', '✗ Baixa precisão (> 100m)']); + } + + 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]; + propriedadesData.push(['Qualidade GPS', `${qualidadeTexto} (${qualidadeGPS.toFixed(0)}%)`]); + + doc.setFontSize(11); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'bold'); + doc.text('Análise de Propriedades GPS:', 15, yPosition); + yPosition += 8; + + autoTable(doc, { + startY: yPosition, + head: [['Propriedade', 'Status']], + body: propriedadesData, + theme: 'striped', + headStyles: { + fillColor: [60, 60, 60], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10 + }, + columnStyles: { + 0: { cellWidth: 80, fontStyle: 'bold' }, + 1: { cellWidth: 110 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 1) { + const texto = data.cell.text[0]; + if (texto.includes('✓')) { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } else if (texto.includes('✗')) { + data.cell.styles.textColor = [200, 0, 0]; + } else if (texto.includes('⚠')) { + data.cell.styles.textColor = [255, 165, 0]; + data.cell.styles.fontStyle = 'bold'; + } + if (data.row.index === propriedadesData.length - 1) { + data.cell.styles.textColor = qualidadeCor; + data.cell.styles.fontStyle = 'bold'; + } + } + } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + return finalY + 10; +} + +/** + * Adiciona seção de geofencing no PDF + */ +async function adicionarSecaoGeofencingPDF( + doc: jsPDF, + yPosition: number, + registro: any, + client: ConvexClient +): Promise { + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('VALIDAÇÃO DE LOCALIZAÇÃO PERMITIDA', 15, yPosition); + yPosition += 10; + + if (registro.enderecoMarcacaoEsperado || registro.dentroRaioPermitido !== undefined) { + let enderecoEsperadoNome = 'Não configurado'; + let enderecoEsperadoEndereco = 'Não configurado'; + let enderecoEsperadoLatitude: number | null = null; + let enderecoEsperadoLongitude: number | null = null; + + if (registro.enderecoMarcacaoEsperado) { + try { + const enderecoEsperado = await client.query(api.enderecosMarcacao.obterEndereco, { + enderecoId: registro.enderecoMarcacaoEsperado + }); + if (enderecoEsperado) { + enderecoEsperadoNome = enderecoEsperado.nome; + enderecoEsperadoEndereco = `${enderecoEsperado.endereco}, ${enderecoEsperado.cidade}/${enderecoEsperado.estado}`; + enderecoEsperadoLatitude = enderecoEsperado.latitude; + enderecoEsperadoLongitude = enderecoEsperado.longitude; + } + } catch (error) { + console.warn('Erro ao buscar endereço esperado:', error); + } + } + + const geofencingData: Array<[string, string]> = [ + ['Endereço Esperado', enderecoEsperadoNome], + ['Localização', enderecoEsperadoEndereco] + ]; + + if (enderecoEsperadoLatitude !== null && enderecoEsperadoLongitude !== null) { + geofencingData.push([ + 'Coordenadas Esperadas', + `${enderecoEsperadoLatitude.toFixed(6)}, ${enderecoEsperadoLongitude.toFixed(6)}` + ]); + } + + geofencingData.push([ + 'Coordenadas do Registro', + `${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}` + ]); + + 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`; + 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`; + geofencingData.push(['Raio Permitido', raioTexto]); + } else { + geofencingData.push(['Raio Permitido', 'Não configurado']); + } + + let statusTexto = 'Não validado'; + if (registro.dentroRaioPermitido === true) { + statusTexto = '✓ DENTRO DO RAIO PERMITIDO'; + } else if (registro.dentroRaioPermitido === false) { + statusTexto = '⚠️ FORA DO RAIO PERMITIDO'; + if ( + registro.distanciaEnderecoEsperado !== null && + registro.distanciaEnderecoEsperado !== undefined && + registro.raioToleranciaUsado !== null && + registro.raioToleranciaUsado !== undefined + ) { + 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`; + geofencingData.push(['Distância Excedente', excedenteTexto]); + } + } + geofencingData.push(['Status', statusTexto]); + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: geofencingData, + theme: 'striped', + headStyles: { + fillColor: [41, 128, 185], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 10 + }, + columnStyles: { + 0: { cellWidth: 60, fontStyle: 'bold' }, + 1: { cellWidth: 130 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 4 }, + didParseCell: (data: any) => { + if (data.section === 'body' && data.column.index === 1) { + const texto = data.cell.text[0]; + if (texto.includes('✓ DENTRO')) { + data.cell.styles.textColor = [0, 128, 0]; + data.cell.styles.fontStyle = 'bold'; + } else if (texto.includes('⚠️ FORA')) { + data.cell.styles.textColor = [200, 0, 0]; + data.cell.styles.fontStyle = 'bold'; + } + } + } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalY + 5; + + if (registro.dentroRaioPermitido === false) { + doc.setFontSize(9); + doc.setTextColor(100, 100, 100); + const observacaoLines = doc.splitTextToSize( + 'O registro foi realizado fora da área permitida de marcação de ponto. Verifique se o funcionário possui autorização para trabalho remoto ou deslocamento.', + 170 + ); + doc.text(observacaoLines, 20, yPosition); + yPosition += observacaoLines.length * 4 + 5; + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + } + + return yPosition + 5; + } 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); + return yPosition + 8; + } +} + +/** + * Adiciona seção de dados técnicos no PDF + */ +function adicionarSecaoDadosTecnicosPDF(doc: jsPDF, yPosition: number, registro: any): number { + doc.setFontSize(12); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('DADOS TÉCNICOS', 15, yPosition); + yPosition += 10; + + const dadosTecnicosData: Array<[string, string]> = []; + + // Informações de Rede + if (registro.ipAddress || registro.ipPublico || registro.ipLocal) { + if (registro.ipAddress) { + dadosTecnicosData.push(['IP', registro.ipAddress]); + } + if (registro.ipPublico) { + dadosTecnicosData.push(['IP Público', registro.ipPublico]); + } + if (registro.ipLocal) { + dadosTecnicosData.push(['IP Local', registro.ipLocal]); + } + } + + // Informações do Navegador + if (registro.browser || registro.userAgent) { + if (registro.browser) { + dadosTecnicosData.push([ + 'Navegador', + `${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}` + ]); + } + if (registro.engine) { + dadosTecnicosData.push(['Engine', registro.engine]); + } + if (registro.userAgent) { + dadosTecnicosData.push(['User Agent', registro.userAgent]); + } + } + + // Informações do Sistema + if (registro.sistemaOperacional || registro.arquitetura) { + if (registro.sistemaOperacional) { + dadosTecnicosData.push([ + 'Sistema Operacional', + `${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}` + ]); + } + if (registro.arquitetura) { + dadosTecnicosData.push(['Arquitetura', registro.arquitetura]); + } + if (registro.plataforma) { + dadosTecnicosData.push(['Plataforma', registro.plataforma]); + } + } + + // Informações do Dispositivo + if (registro.deviceType || registro.screenResolution) { + if (registro.deviceType) { + dadosTecnicosData.push(['Tipo de Dispositivo', registro.deviceType]); + } + if (registro.deviceModel) { + dadosTecnicosData.push(['Modelo', registro.deviceModel]); + } + if (registro.screenResolution) { + dadosTecnicosData.push(['Resolução', registro.screenResolution]); + } + if (registro.coresTela) { + dadosTecnicosData.push(['Cores da Tela', registro.coresTela]); + } + if (registro.isMobile || registro.isTablet || registro.isDesktop) { + const tipoDispositivo = registro.isMobile + ? 'Mobile' + : registro.isTablet + ? 'Tablet' + : 'Desktop'; + dadosTecnicosData.push(['Categoria', tipoDispositivo]); + } + if (registro.idioma) { + dadosTecnicosData.push(['Idioma', registro.idioma]); + } + if (registro.connectionType) { + dadosTecnicosData.push(['Tipo de Conexão', registro.connectionType]); + } + if (registro.memoryInfo) { + dadosTecnicosData.push(['Memória', registro.memoryInfo]); + } + } + + if (dadosTecnicosData.length > 0) { + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: dadosTecnicosData, + theme: 'striped', + headStyles: { + fillColor: [60, 60, 60], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 10 + }, + bodyStyles: { + fontSize: 9, + textColor: [0, 0, 0] + }, + columnStyles: { + 0: { cellWidth: 60, fontStyle: 'bold' }, + 1: { cellWidth: 130 } + }, + margin: { left: 15, right: 15 }, + styles: { cellPadding: 3 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; + return finalY + 10; + } + + return yPosition; +} + +/** + * Adiciona imagem ao PDF + */ +async function adicionarImagemPDF( + doc: jsPDF, + yPosition: number, + imagemUrl: string +): Promise { + yPosition += 10; + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + + doc.setFont('helvetica', 'bold'); + doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' }); + doc.setFont('helvetica', 'normal'); + yPosition += 10; + + try { + const response = await fetch(imagemUrl); + if (!response.ok) { + throw new Error('Erro ao carregar imagem'); + } + + const blob = await response.blob(); + const reader = new FileReader(); + + const base64 = await new Promise((resolve, reject) => { + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Erro ao converter imagem')); + } + }; + reader.onerror = () => reject(new Error('Erro ao ler imagem')); + reader.readAsDataURL(blob); + }); + + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('Erro ao processar imagem')); + img.src = base64; + setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000); + }); + + const maxWidth = 80; + const maxHeight = 60; + let imgWidth = img.width; + let imgHeight = img.height; + const aspectRatio = imgWidth / imgHeight; + + if (imgWidth > maxWidth || imgHeight > maxHeight) { + if (aspectRatio > 1) { + imgWidth = maxWidth; + imgHeight = maxWidth / aspectRatio; + } else { + imgHeight = maxHeight; + imgWidth = maxHeight * aspectRatio; + } + } + + const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2; + + if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPosition = 20; + } + + doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight); + return yPosition + imgHeight + 10; + } catch (error) { + console.warn('Erro ao adicionar imagem ao PDF:', error); + doc.setFontSize(10); + doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' }); + return yPosition + 6; + } +} + +/** + * Adiciona rodapé ao PDF de detalhes + */ +function adicionarRodapeDetalhesPDF(doc: jsPDF): void { + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + + doc.setDrawColor(200, 200, 200); + doc.setLineWidth(0.3); + doc.line( + 15, + doc.internal.pageSize.getHeight() - 20, + 195, + doc.internal.pageSize.getHeight() - 20 + ); + + 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', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + doc.text( + `SGSE - Sistema de Gerenciamento de Secretaria de Esportes`, + 15, + doc.internal.pageSize.getHeight() - 12, + { align: 'left' } + ); + doc.text( + `Gerado em: ${dataGeracao} | Página ${i} de ${pageCount}`, + 195, + doc.internal.pageSize.getHeight() - 12, + { align: 'right' } + ); + } +} + diff --git a/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts b/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts new file mode 100644 index 0000000..9b39b93 --- /dev/null +++ b/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts @@ -0,0 +1,612 @@ +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; +import type { ConvexClient } from 'convex-svelte'; +import { formatarHoraPonto, formatarDataDDMMAAAA, getTipoRegistroLabel } from '../../ponto'; +import type { DiaFichaPonto, ResumoPeriodo, TipoDia } from '../tipos'; +import { processarDadosFichaPonto } from '../processamento'; +import { + adicionarLogo, + adicionarCabecalho, + adicionarDadosFuncionario, + adicionarResumoPeriodo, + adicionarSaldosPeriodo, + adicionarLegenda, + adicionarRodape, + type SectionsPDF +} from '../../fichaPontoPDF'; +import { formatarHoras, formatarMinutos } from '../formatacao'; +import { validarPeriodo } from '../validacao'; + +/** + * Gera PDF com seleção de seções + */ +export async function gerarPDFComSelecao( + client: ConvexClient, + sections: SectionsPDF, + funcionarioId: Id<'funcionarios'>, + dataInicio: string, + dataFim: string, + funcionarios: Array<{ _id: Id<'funcionarios'>; nome: string; matricula?: string }>, + logoGovPE: string, + onError: (message: string) => void, + onSuccess: () => void, + setCarregando: (value: boolean) => void +): Promise { + console.log('[gerarPDFComSelecao] Iniciando geração de PDF', { + funcionarioId, + dataInicio, + dataFim, + sections + }); + + // Verificar se pelo menos uma seção foi selecionada + if (!Object.values(sections).some((v) => v)) { + console.error('[gerarPDFComSelecao] Nenhuma seção selecionada'); + onError('Selecione pelo menos uma seção para imprimir'); + return; + } + + // Validar período + const validacaoPeriodo = validarPeriodo(dataInicio, dataFim); + if (!validacaoPeriodo.valido) { + console.error('[gerarPDFComSelecao] Período inválido', validacaoPeriodo); + onError(validacaoPeriodo.erro || 'Período inválido'); + return; + } + + const funcionario = funcionarios.find((f) => f._id === funcionarioId); + if (!funcionario) { + console.error('[gerarPDFComSelecao] Funcionário não encontrado', funcionarioId); + onError('Funcionário não encontrado'); + return; + } + + try { + setCarregando(true); + console.log('[gerarPDFComSelecao] Processando dados...'); + // Processar todos os dados necessários + const { dias, resumo, config: configPonto } = await processarDadosFichaPonto( + client, + funcionarioId, + dataInicio, + dataFim + ); + + console.log('[gerarPDFComSelecao] Dados processados', { + diasCount: dias.length, + resumo, + config: configPonto + }); + + if (dias.length === 0) { + console.error('[gerarPDFComSelecao] Nenhum dado encontrado'); + onError('Nenhum dado encontrado para este funcionário no período selecionado'); + setCarregando(false); + return; + } + + const doc = new jsPDF(); + + // Logo e cabeçalho + let yPosition = await adicionarLogo(doc, logoGovPE); + yPosition = adicionarCabecalho(doc, yPosition); + + // Dados do Funcionário + if (sections.dadosFuncionario) { + yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim); + } + + // Resumo do Período + yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos); + + // Saldos do Período + yPosition = adicionarSaldosPeriodo(doc, yPosition, resumo, formatarMinutos); + + // Legenda + yPosition = adicionarLegenda(doc, yPosition); + + // SEÇÃO: TABELA PRINCIPAL DE REGISTROS + if (sections.registrosPonto) { + yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections); + } + + // SEÇÃO: BANCO DE HORAS + if (sections.bancoHoras) { + yPosition = await gerarSecaoBancoHorasPDF( + doc, + yPosition, + client, + funcionarioId, + dataInicio, + dataFim, + configPonto + ); + } + + // SEÇÃO: INCONSISTÊNCIAS (usando alteracoesGestor) + if (sections.alteracoesGestor) { + yPosition = gerarSecaoInconsistenciasPDF(doc, yPosition, dias); + } + + // SEÇÃO: AJUSTES (usando alteracoesGestor) + if (sections.alteracoesGestor) { + yPosition = gerarSecaoAjustesPDF(doc, yPosition, dias); + } + + // SEÇÃO: DISPENSAS + if (sections.dispensasRegistro) { + yPosition = gerarSecaoDispensasPDF(doc, yPosition, dias); + } + + // Rodapé + adicionarRodape(doc); + + // Salvar + const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`; + console.log('[gerarPDFComSelecao] Salvando PDF:', nomeArquivo); + doc.save(nomeArquivo); + + console.log('[gerarPDFComSelecao] PDF gerado com sucesso'); + onSuccess(); + } catch (error) { + console.error('Erro ao gerar PDF:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + + // Mensagens de erro mais específicas + if (errorMessage.includes('Configuração de ponto não encontrada')) { + onError('Configuração de ponto não encontrada. Entre em contato com o administrador.'); + } else if (errorMessage.includes('Nenhum dado encontrado')) { + onError('Nenhum dado encontrado para este funcionário no período selecionado.'); + } else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { + onError('Tempo de geração excedido. Tente um período menor (máximo 90 dias).'); + } else { + onError(`Erro ao gerar ficha de ponto: ${errorMessage}`); + } + } finally { + setCarregando(false); + } +} + +/** + * Gera tabela de registros de ponto no PDF + */ +function gerarTabelaRegistrosPDF( + doc: jsPDF, + yPosition: number, + dias: DiaFichaPonto[], + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + nomeEntrada?: string; + nomeSaidaAlmoco?: string; + nomeRetornoAlmoco?: string; + nomeSaida?: string; + }, + sections: SectionsPDF +): number { + if (yPosition > 250) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(14); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('REGISTROS DE PONTO', 15, yPosition); + yPosition += 10; + + // Função auxiliar para obter cor de fundo baseada no tipo de dia + const obterCorFundoTipoDia = (tipoDia: TipoDia): number[] => { + switch (tipoDia) { + case 'atestado': + return [230, 240, 255]; // Azul claro + case 'ausencia': + return [255, 255, 230]; // Amarelo claro + case 'abonado': + return [230, 255, 230]; // Verde claro + case 'nao_computado': + return [240, 240, 240]; // Cinza claro + case 'inconsistente': + return [255, 240, 230]; // Laranja claro + default: + return [255, 255, 255]; // Branco + } + }; + + // Função auxiliar para obter ícone do tipo de dia + const obterIconeTipoDia = (dia: DiaFichaPonto): string => { + if (dia.atestado) return '🏥'; + if (dia.ausencia) return '🚫'; + if (dia.licenca) return '📋'; + if (dia.tipoDia === 'abonado') return '✅'; + if (dia.tipoDia === 'nao_computado') return '⏸'; + if (dia.inconsistencias.length > 0) return '⚠'; + return ''; + }; + + // Preparar dados da tabela + const tableData: Array< + Array< + | string + | { + content: string; + styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string }; + } + > + > = []; + + for (const dia of dias) { + const dataFormatada = dia.dataFormatada; + const todosRegistros = [ + ...dia.registros.map((r) => ({ ...r, real: true })), + ...dia.registrosEsperados + .filter((re) => !dia.registros.some((r) => r.tipo === re.tipo)) + .map((re) => ({ ...re, real: false })) + ].sort((a, b) => { + if (a.hora !== b.hora) return a.hora - b.hora; + return a.minuto - b.minuto; + }); + + for (let i = 0; i < todosRegistros.length; i++) { + const reg = todosRegistros[i]; + const linha: Array< + string | { content: string; styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string } } + > = []; + + // Coluna Data (apenas na primeira linha) + if (i === 0) { + linha.push({ + content: `${dataFormatada} ${obterIconeTipoDia(dia)}`, + styles: { + fillColor: obterCorFundoTipoDia(dia.tipoDia), + fontStyle: 'bold' + } + }); + } else { + linha.push(''); + } + + // Coluna Tipo + const tipoLabel = 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'); + + if (!('real' in reg) || reg.real) { + linha.push(tipoLabel); + } else { + linha.push({ + content: tipoLabel, + styles: { textColor: [200, 0, 0] } // Vermelho para não marcado + }); + } + + // Coluna Horário + const horario = formatarHoraPonto(reg.hora, reg.minuto); + if (!('real' in reg) || reg.real) { + linha.push(horario); + } else { + linha.push({ + content: horario, + styles: { textColor: [200, 0, 0] } // Vermelho para não marcado + }); + } + + // Coluna Saldo Diário (se seção selecionada) + if (sections.saldoDiario) { + if (i === 0 && dia.saldoDiario) { + const saldoFormatado = formatarMinutos(dia.saldoDiario.diferencaMinutos); + const corSaldo = dia.saldoDiario.diferencaMinutos < 0 ? [200, 0, 0] : [0, 128, 0]; + linha.push({ + content: saldoFormatado, + styles: { textColor: corSaldo, fontStyle: 'bold' } + }); + } else { + linha.push(''); + } + } + + // Coluna Observações (apenas na primeira linha) + if (i === 0) { + const observacoes: string[] = []; + if (dia.atestado) { + observacoes.push(`Atestado: ${dia.atestado.tipo}`); + } + if (dia.ausencia) { + observacoes.push(`Ausência: ${dia.ausencia.motivo}`); + } + if (dia.licenca) { + observacoes.push(`Licença: ${dia.licenca.tipo}`); + } + if (dia.dispensa) { + observacoes.push(`Dispensa: ${dia.dispensa.motivo}`); + } + if (dia.inconsistencias.length > 0) { + observacoes.push(`Inconsistências: ${dia.inconsistencias.length}`); + } + if (dia.ajustes.length > 0) { + observacoes.push( + `Ajustes: ${dia.ajustes.map((a) => `${a.tipo} ${formatarMinutos(a.valorMinutos)}`).join(', ')}` + ); + } + + linha.push(observacoes.join('; ') || '-'); + } else { + linha.push(''); + } + + // Coluna Dentro do Prazo + if ('real' in reg && reg.real && 'dentroDoPrazo' in reg) { + linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não'); + } else { + linha.push('Não marcado'); + } + + tableData.push(linha); + } + } + + // Cabeçalhos da tabela + const headers = ['Data', 'Tipo', 'Horário']; + if (sections.saldoDiario) { + headers.push('Saldo Diário'); + } + headers.push('Observações', 'Dentro do Prazo'); + + // Adicionar tabela + autoTable(doc, { + startY: yPosition, + head: [headers], + body: tableData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 }, + didParseCell: function (data: any) { + if (data.section === 'body' && data.cell.raw) { + const cellData = data.cell.raw; + if (typeof cellData === 'object' && cellData.styles) { + if (cellData.styles.fillColor) { + data.cell.styles.fillColor = cellData.styles.fillColor; + } + if (cellData.styles.textColor) { + data.cell.styles.textColor = cellData.styles.textColor; + } + if (cellData.styles.fontStyle) { + data.cell.styles.fontStyle = cellData.styles.fontStyle; + } + } + } + } + }); + + // Calcular nova posição Y + const lastPage = doc.getNumberOfPages(); + doc.setPage(lastPage); + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY; + return finalY ? finalY + 10 : yPosition + tableData.length * 7 + 10; +} + +/** + * Gera seção de banco de horas no PDF + */ +async function gerarSecaoBancoHorasPDF( + doc: jsPDF, + yPosition: number, + client: ConvexClient, + funcionarioId: Id<'funcionarios'>, + dataInicio: string, + dataFim: string, + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + } +): Promise { + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('BANCO DE HORAS', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + // Buscar banco de horas + const { api } = await import('@sgse-app/backend/convex/_generated/api'); + const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, { + funcionarioId + }); + + if (bancoHoras) { + const bancoData = [ + ['Saldo Atual', formatarMinutos(bancoHoras.saldoAtualMinutos || 0)], + ['Saldo Inicial', formatarMinutos(bancoHoras.saldoInicialMinutos || 0)], + ['Saldo Final', formatarMinutos(bancoHoras.saldoFinalMinutos || 0)] + ]; + + autoTable(doc, { + startY: yPosition, + head: [['Campo', 'Valor']], + body: bancoData, + theme: 'striped', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 10 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY; + return finalY ? finalY + 10 : yPosition + bancoData.length * 7 + 10; + } + + return yPosition; +} + +/** + * Gera seção de inconsistências no PDF + */ +function gerarSecaoInconsistenciasPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number { + const todasInconsistencias = dias.flatMap((dia) => + dia.inconsistencias.map((inc) => ({ + ...inc, + data: dia.data, + dataFormatada: dia.dataFormatada + })) + ); + + if (todasInconsistencias.length === 0) { + return yPosition; + } + + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('INCONSISTÊNCIAS', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const inconsistenciasData = todasInconsistencias.map((inc) => [ + formatarDataDDMMAAAA(inc.data), + inc.tipo, + inc.descricao, + inc.status + ]); + + autoTable(doc, { + startY: yPosition, + head: [['Data', 'Tipo', 'Descrição', 'Status']], + body: inconsistenciasData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY; + return finalY ? finalY + 10 : yPosition + inconsistenciasData.length * 7 + 10; +} + +/** + * Gera seção de ajustes no PDF + */ +function gerarSecaoAjustesPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number { + const todosAjustes = dias.flatMap((dia) => + dia.ajustes.map((ajuste) => ({ + ...ajuste, + data: dia.data, + dataFormatada: dia.dataFormatada + })) + ); + + if (todosAjustes.length === 0) { + return yPosition; + } + + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('AJUSTES DE BANCO DE HORAS', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const ajustesData = todosAjustes.map((ajuste) => [ + formatarDataDDMMAAAA(ajuste.data), + ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar', + formatarMinutos(ajuste.valorMinutos), + ajuste.motivoDescricao || '-' + ]); + + autoTable(doc, { + startY: yPosition, + head: [['Data', 'Tipo', 'Valor', 'Motivo']], + body: ajustesData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY; + return finalY ? finalY + 10 : yPosition + ajustesData.length * 7 + 10; +} + +/** + * Gera seção de dispensas no PDF + */ +function gerarSecaoDispensasPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number { + const dispensas = dias + .map((dia) => dia.dispensa) + .filter((d): d is NonNullable => d !== null) + .filter((d, index, self) => index === self.findIndex((disp) => disp._id === d._id)); + + if (dispensas.length === 0) { + return yPosition; + } + + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('DISPENSAS DE REGISTRO', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const dispensasData = dispensas.map((d) => [ + `${formatarDataDDMMAAAA(d.dataInicio)} a ${formatarDataDDMMAAAA(d.dataFim)}`, + d.motivo, + d.ativo ? 'Ativa' : 'Inativa' + ]); + + autoTable(doc, { + startY: yPosition, + head: [['Período', 'Motivo', 'Status']], + body: dispensasData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 } + }); + + type JsPDFWithAutoTable = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY; + return finalY ? finalY + 10 : yPosition + dispensasData.length * 7 + 10; +} + diff --git a/apps/web/src/lib/utils/ponto/processamento.ts b/apps/web/src/lib/utils/ponto/processamento.ts new file mode 100644 index 0000000..63a19a7 --- /dev/null +++ b/apps/web/src/lib/utils/ponto/processamento.ts @@ -0,0 +1,684 @@ +import type { ConvexClient } from 'convex-svelte'; +import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; +import { api } from '@sgse-app/backend/convex/_generated/api'; +import type { DiaFichaPonto, ResumoPeriodo, RegistroPonto, TipoDia } from './tipos'; +import { calcularSaldoComparativoPorPar } from './calculos'; +import { registroFoiMarcado } from './validacao'; +import { formatarDataDDMMAAAA } from '../ponto'; +import { formatarMinutos, formatarHoras } from './formatacao'; + +/** + * Gera array de todas as datas do período selecionado + */ +export function gerarDiasPeriodo(dataInicio: string, dataFim: string): string[] { + 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 + */ +export 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 [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 } + ]; +} + +/** + * Agrupa registros por funcionário e data + */ +export function agruparRegistrosPorFuncionario( + registros: Array<{ + _id: Id<'registrosPonto'>; + funcionarioId: Id<'funcionarios'>; + data: string; + funcionario?: { nome: string; matricula?: string; descricaoCargo?: string } | null; + [key: string]: any; + }>, + config?: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + } +): Array<{ + funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null; + funcionarioId: Id<'funcionarios'>; + registrosPorData: Record< + string, + { + data: string; + registros: typeof registros; + saldoDiario?: { + saldoMinutos: number; + horas: number; + minutos: number; + positivo: boolean; + }; + saldoDiarioComparativo?: { + trabalhadoMinutos: number; + esperadoMinutos: number; + diferencaMinutos: number; + }; + } + >; +}> { + const agrupados: Record< + string, + { + funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null; + funcionarioId: Id<'funcionarios'>; + registrosPorData: Record< + string, + { + data: string; + registros: typeof registros; + saldoDiario?: { + saldoMinutos: number; + horas: number; + minutos: number; + positivo: boolean; + }; + saldoDiarioComparativo?: { + trabalhadoMinutos: number; + esperadoMinutos: number; + diferencaMinutos: number; + }; + } + >; + } + > = {}; + + const registrosProcessados = new Set(); + + if (!Array.isArray(registros) || registros.length === 0) { + return []; + } + + for (const registro of registros) { + if (!registro || !registro._id || !registro.funcionarioId || !registro.data) { + continue; + } + + const chaveUnica = `${registro._id}`; + if (registrosProcessados.has(chaveUnica)) { + continue; + } + registrosProcessados.add(chaveUnica); + + const key = registro.funcionarioId; + if (!agrupados[key]) { + agrupados[key] = { + funcionario: registro.funcionario, + funcionarioId: registro.funcionarioId, + registrosPorData: {} + }; + } + + const dataKey = registro.data; + if (!agrupados[key]!.registrosPorData[dataKey]) { + agrupados[key]!.registrosPorData[dataKey] = { + data: dataKey, + registros: [], + saldoDiario: undefined + }; + } + + const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some( + (r) => r._id === registro._id + ); + if (!jaExiste) { + agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro); + } + } + + const resultado = Object.values(agrupados); + + resultado.sort((a, b) => { + const nomeA = a.funcionario?.nome || ''; + const nomeB = b.funcionario?.nome || ''; + return nomeA.localeCompare(nomeB, 'pt-BR'); + }); + + for (const grupo of resultado) { + const datasOrdenadas = Object.keys(grupo.registrosPorData).sort((a, b) => { + return new Date(b).getTime() - new Date(a).getTime(); + }); + + const registrosPorDataOrdenado: Record = {}; + for (const dataKey of datasOrdenadas) { + registrosPorDataOrdenado[dataKey] = grupo.registrosPorData[dataKey]!; + } + grupo.registrosPorData = registrosPorDataOrdenado; + + for (const dataKey in grupo.registrosPorData) { + const grupoData = grupo.registrosPorData[dataKey]; + if (grupoData && grupoData.registros.length > 0) { + grupoData.registros.sort((a, b) => { + if (a.hora !== b.hora) { + return a.hora - b.hora; + } + return a.minuto - b.minuto; + }); + + if (config) { + const regsReaisOrdenados = [...grupoData.registros].sort((a, b) => { + if (a.hora !== b.hora) return a.hora - b.hora; + return a.minuto - b.minuto; + }); + const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config); + + let totalTrabalhado = 0; + const paresProcessados = new Set(); + for (const [, saldo] of saldosComparativosPorPar.entries()) { + if (!paresProcessados.has(saldo.parIndex)) { + totalTrabalhado += saldo.trabalhadoMinutos; + paresProcessados.add(saldo.parIndex); + } + } + + 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); + + const minutosPar1EsperadoConfig = + horaSaidaAlmocoConfig * 60 + + minutoSaidaAlmocoConfig - + (horaEntradaConfig * 60 + minutoEntradaConfig); + const minutosPar1EsperadoAjustadoConfig = + minutosPar1EsperadoConfig < 0 + ? minutosPar1EsperadoConfig + 24 * 60 + : minutosPar1EsperadoConfig; + + const minutosPar2EsperadoConfig = + horaSaidaConfig * 60 + + minutoSaidaConfig - + (horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig); + const minutosPar2EsperadoAjustadoConfig = + minutosPar2EsperadoConfig < 0 + ? minutosPar2EsperadoConfig + 24 * 60 + : minutosPar2EsperadoConfig; + + const cargaHorariaDiariaEsperadaMinutos = + minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig; + + const diferencaMinutos = totalTrabalhado - cargaHorariaDiariaEsperadaMinutos; + + grupoData.saldoDiarioComparativo = { + trabalhadoMinutos: totalTrabalhado, + esperadoMinutos: cargaHorariaDiariaEsperadaMinutos, + diferencaMinutos: diferencaMinutos + }; + } + } + } + } + + return resultado.filter((grupo) => { + const temRegistros = Object.values(grupo.registrosPorData).some( + (grupoData) => grupoData.registros && grupoData.registros.length > 0 + ); + return temRegistros; + }); +} + +/** + * Processa dados para ficha de ponto + * Esta é uma função grande que processa todos os dados necessários para gerar a ficha + */ +export async function processarDadosFichaPonto( + client: ConvexClient, + funcionarioId: Id<'funcionarios'>, + dataInicio: string, + dataFim: string +): Promise<{ + dias: DiaFichaPonto[]; + resumo: ResumoPeriodo; + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + }; +}> { + // Buscar todos os dados necessários + const [ + registrosFuncionario, + atestadosLicencas, + ausenciasTodas, + ajustes, + inconsistencias, + homologacoes, + dispensas, + config + ] = await Promise.all([ + client.query(api.pontos.listarRegistrosPeriodo, { + funcionarioId, + dataInicio, + dataFim + }), + client.query(api.atestadosLicencas.listarPorFuncionario, { + funcionarioId + }), + client.query(api.ausencias.listarTodas, {}), + client.query(api.pontos.listarAjustesBancoHoras, { + funcionarioId + }), + client.query(api.pontos.listarInconsistenciasBancoHoras, {}), + client.query(api.pontos.listarHomologacoes, { + funcionarioId + }), + client.query(api.pontos.listarDispensas, { + funcionarioId, + apenasAtivas: false + }), + client.query(api.configuracaoPonto.obterConfiguracao, {}) + ]); + + const atestados = atestadosLicencas?.atestados || []; + const licencas = atestadosLicencas?.licencas || []; + const ausencias = (ausenciasTodas || []).filter((a) => a.funcionarioId === funcionarioId); + + if (!config) { + throw new Error('Configuração de ponto não encontrada'); + } + + // Filtrar dados pelo período + const dataInicioObj = new Date(dataInicio + 'T00:00:00'); + const dataFimObj = new Date(dataFim + 'T23:59:59'); + + const atestadosPeriodo = (atestados || []).filter((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return inicio <= dataFimObj && fim >= dataInicioObj; + }); + + const ausenciasPeriodo = (ausencias || []).filter((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return inicio <= dataFimObj && fim >= dataInicioObj; + }); + + const licencasPeriodo = (licencas || []).filter((l) => { + const inicio = new Date(l.dataInicio); + const fim = new Date(l.dataFim); + return inicio <= dataFimObj && fim >= dataInicioObj; + }); + + const ajustesPeriodo = (ajustes || []).filter((a) => { + const dataAjuste = new Date(a.dataAplicacao); + return dataAjuste >= dataInicioObj && dataAjuste <= dataFimObj; + }); + + const inconsistenciasPeriodo = (inconsistencias || []).filter((i) => { + if (i.funcionarioId !== funcionarioId) return false; + const dataInconsistencia = new Date(i.dataDetectada); + return dataInconsistencia >= dataInicioObj && dataInconsistencia <= dataFimObj; + }); + + const dataInicioTimestamp = dataInicioObj.getTime(); + const dataFimTimestamp = dataFimObj.getTime(); + const homologacoesPeriodo = (homologacoes || []).filter((h) => { + return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp; + }); + + const dispensasPeriodo = (dispensas || []).filter((d) => { + const dispensaInicio = new Date(d.dataInicio + 'T00:00:00'); + const dispensaFim = new Date(d.dataFim + 'T23:59:59'); + return dispensaInicio <= dataFimObj && dispensaFim >= dataInicioObj; + }); + + // Gerar todos os dias do período + const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim); + const diasProcessados: DiaFichaPonto[] = []; + + // Agrupar registros por data + const registrosPorData: Record = {}; + for (const r of registrosFuncionario || []) { + if (!registrosPorData[r.data]) { + registrosPorData[r.data] = []; + } + registrosPorData[r.data]!.push(r); + } + + // Processar cada dia + for (const data of diasPeriodo) { + const dataObj = new Date(data); + const regsReais = registrosPorData[data] || []; + const regsEsperados = gerarRegistrosEsperados(data, config); + + // Verificar atestado + const atestadoDia = + atestadosPeriodo.find((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return dataObj >= inicio && dataObj <= fim; + }) || null; + + // Verificar ausência + const ausenciaDia = + ausenciasPeriodo.find((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return dataObj >= inicio && dataObj <= fim; + }) || null; + + // Verificar licença + const licencaDia = + licencasPeriodo.find((l) => { + const inicio = new Date(l.dataInicio); + const fim = new Date(l.dataFim); + return dataObj >= inicio && dataObj <= fim; + }) || null; + + // Verificar ajustes do dia + const ajustesDia = ajustesPeriodo.filter((a) => a.dataAplicacao === data); + + // Verificar inconsistências do dia + const inconsistenciasDia = inconsistenciasPeriodo.filter((i) => i.dataDetectada === data); + + // Verificar homologações do dia + const homologacoesDia = homologacoesPeriodo.filter((h) => { + if (h.registroId) { + const registro = regsReais.find((r) => r._id === h.registroId); + return registro !== undefined; + } + return false; + }); + + // Verificar dispensa + const dispensaDia = + dispensasPeriodo.find((d) => { + const dispensaInicio = new Date(d.dataInicio + 'T00:00:00'); + const dispensaFim = new Date(d.dataFim + 'T23:59:59'); + return dataObj >= dispensaInicio && dataObj <= dispensaFim; + }) || null; + + // Calcular saldo diário + const regsReaisOrdenados = [...regsReais].sort((a, b) => { + if (a.hora !== b.hora) return a.hora - b.hora; + return a.minuto - b.minuto; + }); + const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config); + + let saldoDiario: { diferencaMinutos: number; trabalhadoMinutos: number; esperadoMinutos: number } | null = null; + let saldoDiarioTotalDiferencaMinutos = 0; + let saldoDiarioTotalTrabalhadoMinutos = 0; + let saldoDiarioTotalEsperadoMinutos = 0; + + // Somar saldos dos pares + const paresProcessados = new Set(); + for (const [, saldo] of saldosComparativosPorPar.entries()) { + if (!paresProcessados.has(saldo.parIndex)) { + saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos; + saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos; + saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos; + paresProcessados.add(saldo.parIndex); + } + } + + // Calcular saldo para pares não marcados + const todosRegistros: Array<{ tipo: string; hora: number; minuto: number; real: boolean }> = []; + for (const reg of regsReais) { + todosRegistros.push({ + tipo: reg.tipo, + hora: reg.hora, + minuto: reg.minuto, + real: true + }); + } + for (const regEsperado of regsEsperados) { + if (!registroFoiMarcado(regEsperado, regsReais)) { + todosRegistros.push({ + tipo: regEsperado.tipo, + hora: regEsperado.hora, + minuto: regEsperado.minuto, + real: false + }); + } + } + + // Identificar pares não marcados e calcular saldo negativo + for (let i = 0; i < todosRegistros.length; i++) { + const reg = todosRegistros[i]; + if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) { + const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida'; + const saidaEsperada = todosRegistros.find((r, idx) => { + if (idx <= i) return false; + if (r.tipo !== tipoSaidaEsperado || r.real) return false; + const minutosEntrada = reg.hora * 60 + reg.minuto; + const minutosSaidaEsperada = r.hora * 60 + r.minuto; + const temRegistroRealNoIntervalo = regsReais.some((real) => { + if (real.tipo !== tipoSaidaEsperado) return false; + const minutosReal = real.hora * 60 + real.minuto; + return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada; + }); + return !temRegistroRealNoIntervalo; + }); + + if (saidaEsperada) { + let esperadoMinutos: number; + if (reg.tipo === 'entrada') { + 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; + 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 minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado; + esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada; + if (esperadoMinutos < 0) esperadoMinutos += 24 * 60; + } + + saldoDiarioTotalDiferencaMinutos -= esperadoMinutos; + saldoDiarioTotalEsperadoMinutos += esperadoMinutos; + } + } + } + + // Aplicar ajustes manuais + for (const ajuste of ajustesDia) { + if (ajuste.tipo === 'abonar') { + saldoDiarioTotalDiferencaMinutos += ajuste.valorMinutos; + } else if (ajuste.tipo === 'descontar') { + saldoDiarioTotalDiferencaMinutos -= ajuste.valorMinutos; + } + } + + // Calcular diferença final + const diferencaDiariaCorrigida = + saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos; + + saldoDiario = { + diferencaMinutos: diferencaDiariaCorrigida, + trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos, + esperadoMinutos: saldoDiarioTotalEsperadoMinutos + }; + + // Determinar tipo de dia + let tipoDia: TipoDia = 'normal'; + let computado = true; + + if (dispensaDia) { + tipoDia = 'nao_computado'; + computado = false; + } else if (licencaDia) { + tipoDia = 'licenca'; + computado = false; + } else if (atestadoDia) { + tipoDia = 'atestado'; + computado = false; + } else if (ausenciaDia) { + tipoDia = 'ausencia'; + computado = false; + } else if (ajustesDia.some((a) => a.tipo === 'abonar' && a.valorMinutos >= 240)) { + tipoDia = 'abonado'; + } + + if (inconsistenciasDia.length > 0) { + tipoDia = 'inconsistente'; + } + + diasProcessados.push({ + data, + dataFormatada: formatarDataDDMMAAAA(data), + tipoDia, + registros: regsReais, + registrosEsperados: regsEsperados, + saldoDiario, + saldoAcumulado: 0, // Será calculado depois + atestado: atestadoDia + ? { + _id: atestadoDia._id, + tipo: atestadoDia.tipo, + dataInicio: atestadoDia.dataInicio, + dataFim: atestadoDia.dataFim, + motivo: atestadoDia.observacoes + } + : null, + ausencia: ausenciaDia + ? { + _id: ausenciaDia._id, + motivo: ausenciaDia.motivo, + dataInicio: ausenciaDia.dataInicio, + dataFim: ausenciaDia.dataFim, + status: ausenciaDia.status + } + : null, + licenca: licencaDia + ? { + _id: licencaDia._id, + tipo: licencaDia.tipo || 'licenca', + dataInicio: licencaDia.dataInicio, + dataFim: licencaDia.dataFim + } + : null, + ajustes: ajustesDia.map((a) => ({ + _id: a._id, + tipo: a.tipo, + valorMinutos: a.valorMinutos, + motivoDescricao: a.motivoDescricao, + gestorId: a.gestorId + })), + inconsistencias: inconsistenciasDia.map((i) => ({ + _id: i._id, + tipo: i.tipo, + descricao: i.descricao, + dataDetectada: i.dataDetectada, + status: i.status, + resolvidoPor: i.resolvidoPor, + resolvidoEm: i.resolvidoEm + })), + homologacoes: homologacoesDia.map((h) => ({ + _id: h._id, + motivoDescricao: h.motivoDescricao, + gestorId: h.gestorId + })), + dispensa: dispensaDia + ? { + _id: dispensaDia._id, + motivo: dispensaDia.motivo, + dataInicio: dispensaDia.dataInicio, + dataFim: dispensaDia.dataFim, + ativo: dispensaDia.ativo + } + : null, + computado + }); + } + + // Calcular saldo acumulado para cada dia + let saldoAcumulado = 0; + + for (const dia of diasProcessados) { + if (dia.computado && dia.saldoDiario) { + saldoAcumulado += dia.saldoDiario.diferencaMinutos; + } + dia.saldoAcumulado = saldoAcumulado; + } + + // Calcular resumo com formatações + const totalHorasTrabalhadas = diasProcessados + .filter((d) => d.computado) + .reduce((acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0), 0); + const totalHorasEsperadas = diasProcessados + .filter((d) => d.computado) + .reduce((acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0), 0); + const diferencaTotal = diasProcessados + .filter((d) => d.computado) + .reduce((acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0), 0); + const saldoPeriodo = diferencaTotal; + const saldoFinal = + diasProcessados.length > 0 ? diasProcessados[diasProcessados.length - 1]!.saldoAcumulado : 0; + + const resumo: ResumoPeriodo = { + totalDias: diasProcessados.length, + diasTrabalhados: diasProcessados.filter((d) => d.computado && d.registros.length > 0).length, + diasComAtestado: diasProcessados.filter((d) => d.atestado !== null).length, + diasAusentes: diasProcessados.filter((d) => d.ausencia !== null).length, + diasComLicenca: diasProcessados.filter((d) => d.licenca !== null).length, + diasAbonados: diasProcessados.filter((d) => d.tipoDia === 'abonado').length, + diasNaoComputados: diasProcessados.filter((d) => !d.computado).length, + diasComInconsistencia: diasProcessados.filter((d) => d.inconsistencias.length > 0).length, + totalHorasTrabalhadas, + totalHorasEsperadas, + diferencaTotal, + saldoInicial: 0, + saldoFinal, + saldoPeriodo, + totalInconsistencias: inconsistenciasPeriodo.length, + saldoInicialFormatado: formatarMinutos(0), + saldoPeriodoFormatado: formatarMinutos(saldoPeriodo), + saldoFinalFormatado: formatarMinutos(saldoFinal), + totalHorasTrabalhadasFormatado: formatarHoras(totalHorasTrabalhadas), + totalHorasEsperadasFormatado: formatarHoras(totalHorasEsperadas), + diferencaTotalFormatado: formatarMinutos(diferencaTotal) + }; + + return { + dias: diasProcessados, + resumo, + config + }; +} + diff --git a/apps/web/src/lib/utils/ponto/tipos.ts b/apps/web/src/lib/utils/ponto/tipos.ts new file mode 100644 index 0000000..a70e86d --- /dev/null +++ b/apps/web/src/lib/utils/ponto/tipos.ts @@ -0,0 +1,111 @@ +import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; + +export type TipoDia = + | 'normal' + | 'atestado' + | 'ausencia' + | 'licenca' + | 'abonado' + | 'nao_computado' + | 'ferias' + | 'inconsistente'; + +export interface SaldoDiario { + diferencaMinutos: number; + trabalhadoMinutos: number; + esperadoMinutos: number; +} + +export interface RegistroPonto { + _id: Id<'registrosPonto'>; + tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'; + data: string; + hora: number; + minuto: number; + timestamp: number; + dentroDoPrazo: boolean; +} + +export interface DiaFichaPonto { + data: string; + dataFormatada: string; + tipoDia: TipoDia; + registros: RegistroPonto[]; + registrosEsperados: Array<{ tipo: string; hora: number; minuto: number; data: string }>; + saldoDiario: SaldoDiario | null; + saldoAcumulado: number; + atestado: { + _id: Id<'atestados'>; + tipo: string; + dataInicio: string; + dataFim: string; + motivo?: string; + } | null; + ausencia: { + _id: Id<'solicitacoesAusencias'>; + motivo: string; + dataInicio: string; + dataFim: string; + status: string; + } | null; + licenca: { + _id: Id<'licencas'>; + tipo: string; + dataInicio: string; + dataFim: string; + } | null; + ajustes: Array<{ + _id: Id<'ajustesBancoHoras'>; + tipo: 'abonar' | 'descontar' | 'compensar'; + valorMinutos: number; + motivoDescricao?: string; + gestorId?: Id<'usuarios'>; + }>; + inconsistencias: Array<{ + _id: Id<'inconsistenciasBancoHoras'>; + tipo: string; + descricao: string; + dataDetectada: string; + status: 'pendente' | 'resolvida' | 'ignorada'; + resolvidoPor?: Id<'usuarios'>; + resolvidoEm?: number; + }>; + homologacoes: Array<{ + _id: Id<'homologacoesPonto'>; + motivoDescricao?: string; + gestorId: Id<'usuarios'>; + }>; + dispensa: { + _id: Id<'dispensasRegistro'>; + motivo: string; + dataInicio: string; + dataFim: string; + ativo: boolean; + } | null; + computado: boolean; +} + +export interface ResumoPeriodo { + totalDias: number; + diasTrabalhados: number; + diasComAtestado: number; + diasAusentes: number; + diasComLicenca: number; + diasAbonados: number; + diasNaoComputados: number; + diasComInconsistencia: number; + totalHorasTrabalhadas: number; + totalHorasEsperadas: number; + diferencaTotal: number; + saldoInicial: number; + saldoFinal: number; + saldoPeriodo: number; + totalInconsistencias: number; + saldoInicialFormatado?: string; + saldoPeriodoFormatado?: string; + saldoFinalFormatado?: string; + totalHorasTrabalhadasFormatado?: string; + totalHorasEsperadasFormatado?: string; + diferencaTotalFormatado?: string; +} + diff --git a/apps/web/src/lib/utils/ponto/validacao.ts b/apps/web/src/lib/utils/ponto/validacao.ts new file mode 100644 index 0000000..c20d72f --- /dev/null +++ b/apps/web/src/lib/utils/ponto/validacao.ts @@ -0,0 +1,43 @@ +/** + * Funções de validação para dados de ponto + */ + +/** + * Valida se um período de datas é válido + */ +export function validarPeriodo(dataInicio: string, dataFim: string): { valido: boolean; erro?: string } { + const inicio = new Date(dataInicio); + const fim = new Date(dataFim); + const hoje = new Date(); + hoje.setHours(23, 59, 59, 999); + + if (isNaN(inicio.getTime()) || isNaN(fim.getTime())) { + return { valido: false, erro: 'Datas inválidas' }; + } + + if (inicio > fim) { + return { valido: false, erro: 'Data de início deve ser anterior à data de fim' }; + } + + const diasDiferenca = Math.ceil((fim.getTime() - inicio.getTime()) / (1000 * 60 * 60 * 24)); + if (diasDiferenca > 90) { + return { valido: false, erro: 'Período máximo é de 90 dias' }; + } + + if (fim > hoje) { + return { valido: false, erro: 'Data de fim não pode ser no futuro' }; + } + + return { valido: true }; +} + +/** + * Verifica se um registro esperado foi marcado + */ +export function registroFoiMarcado( + registroEsperado: { tipo: string; hora: number; minuto: number; data: string }, + registrosReais: Array<{ tipo: string; hora: number; minuto: number; data: string }> +): boolean { + return registrosReais.some((r) => r.tipo === registroEsperado.tipo); +} + diff --git a/apps/web/src/routes/(dashboard)/+error.svelte b/apps/web/src/routes/(dashboard)/+error.svelte index 89d736d..4a2aad4 100644 --- a/apps/web/src/routes/(dashboard)/+error.svelte +++ b/apps/web/src/routes/(dashboard)/+error.svelte @@ -69,3 +69,7 @@ + + + + diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index f860547..2914d2d 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -745,6 +745,12 @@ > 🏖️ Em Férias + {:else if funcionario?.statusFerias === 'em_licenca'} +
+ 📋 Em licença +
{:else}

Status

- {funcionario?.statusFerias === 'em_ferias' ? 'Em Férias' : 'Ativo'} + {funcionario?.statusFerias === 'em_ferias' + ? 'Em Férias' + : funcionario?.statusFerias === 'em_licenca' + ? 'Em licença' + : 'Ativo'}

@@ -1180,6 +1194,10 @@
🏖️ Em Férias
+ {:else if funcionario.statusFerias === 'em_licenca'} +
+ 📋 Em licença +
{:else}
✅ Ativo diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte index d88281c..808134c 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte @@ -21,6 +21,7 @@ AlertCircle } from 'lucide-svelte'; import { useConvexClient } from 'convex-svelte'; + import { goto } from '$app/navigation'; import LineChart from '$lib/components/ti/charts/LineChart.svelte'; import jsPDF from 'jspdf'; import logoGovPE from '$lib/assets/logo_governo_PE.png'; @@ -37,6 +38,39 @@ let statusInconsistenciaFiltro = $state(''); let mostrarModalAjuste = $state(false); let funcionarioSelecionado = $state | null>(null); + let mostrarModalInconsistencia = $state(false); + let inconsistenciaSelecionada = $state< + | { + _id: Id<'inconsistenciasBancoHoras'>; + funcionarioId: Id<'funcionarios'>; + tipo: + | 'ponto_com_atestado' + | 'ponto_com_licenca' + | 'ponto_com_ausencia' + | 'registro_duplicado' + | 'sequencia_invalida' + | 'saldo_inconsistente'; + descricao: string; + dataDetectada: string; + dataInconsistencia: string; + status: 'pendente' | 'resolvida' | 'ignorada'; + funcionario: { nome: string; matricula?: string } | null; + } + | null + >(null); + let mostrarModalDetalhesBancoHoras = $state(false); + let detalhesBancoHorasSelecionado = $state< + | { + funcionario: { _id: Id<'funcionarios'>; nome: string; matricula?: string; fotoPerfilUrl?: string | null }; + saldoInicialMinutos: number; + saldoMesMinutos: number; + saldoFinalMinutos: number; + diasTrabalhados: number; + horasExtras: number; + horasDeficit: number; + } + | null + >(null); // Queries const funcionariosQuery = useQuery(api.funcionarios.listar, {}); @@ -392,10 +426,18 @@
-
- - {item.funcionario.nome.substring(0, 2).toUpperCase()} - +
+ {#if item.funcionario.fotoPerfilUrl} + {`Foto + {:else} + + {item.funcionario.nome.substring(0, 2).toUpperCase()} + + {/if}
@@ -457,20 +499,33 @@ type="button" class="btn btn-sm btn-primary" onclick={() => { - funcionarioSelecionado = item.funcionario._id; - mostrarModalAjuste = true; + goto( + `/recursos-humanos/controle-ponto/homologacao?funcionarioId=${item.funcionario._id}` + ); }} - title="Criar ajuste" + title="Criar ajuste de banco de horas" > - { + detalhesBancoHorasSelecionado = { + funcionario: item.funcionario, + saldoInicialMinutos: item.saldoInicialMinutos, + saldoMesMinutos: item.saldoMesMinutos, + saldoFinalMinutos: item.saldoFinalMinutos, + diasTrabalhados: item.diasTrabalhados, + horasExtras: item.horasExtras, + horasDeficit: item.horasDeficit + }; + mostrarModalDetalhesBancoHoras = true; + }} > - +
@@ -564,13 +619,17 @@ - { + inconsistenciaSelecionada = inconsistencia; + mostrarModalInconsistencia = true; + }} > - + {/each} @@ -627,6 +686,308 @@
{/if} + +{#if mostrarModalInconsistencia && inconsistenciaSelecionada} + +{/if} + + +{#if mostrarModalDetalhesBancoHoras && detalhesBancoHorasSelecionado} + +{/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 1273f06..2bca1a7 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte @@ -2986,9 +2986,6 @@
- d="M17 17h2a2 2 0 002-2v-5a2 2 0 00-2-2h-2V6a2 2 0 00-2-2H9a2 2 0 00-2 2v2H5a2 2 0 00-2 2v5a2 2 0 002 2h2m10 0v2a2 2 0 01-2 2H9a2 2 0 01-2-2v-2m10 0H7" - /> -

diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte index f23a37d..c59db05 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte @@ -238,6 +238,8 @@
{#if funcionario.statusFerias === 'em_ferias'}
🏖️ Em Férias
+ {:else if funcionario.statusFerias === 'em_licenca'} +
📋 Em licença
{:else}
✅ Ativo
{/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 8663c72..f9abebf 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 @@ -13,10 +13,38 @@ XCircle, TrendingUp, TrendingDown, - FileText + FileText, + X, + Calendar } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarHoraPonto, getTipoRegistroLabel, formatarDataDDMMAAAA } from '$lib/utils/ponto'; + import { + adicionarLogo, + adicionarCabecalho, + adicionarDadosFuncionario, + adicionarResumoPeriodo, + adicionarSaldosPeriodo, + adicionarLegenda, + adicionarRodape, + verificarNovaPagina, + type SectionsPDF, + type FuncionarioPDF, + type ConfigPontoPDF + } from '$lib/utils/fichaPontoPDF'; + // Importar módulos extraídos + import type { TipoDia, SaldoDiario, RegistroPonto, DiaFichaPonto, ResumoPeriodo } from '$lib/utils/ponto/tipos'; + import { formatarDataParaExibicao, formatarDataParaBackend, formatarSaldoHoras, formatarSaldoDiario, formatarMinutos, formatarHoras } from '$lib/utils/ponto/formatacao'; + import { validarPeriodo } from '$lib/utils/ponto/validacao'; + import { calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar } from '$lib/utils/ponto/calculos'; + import { agruparRegistrosPorFuncionario, gerarDiasPeriodo, gerarRegistrosEsperados, processarDadosFichaPonto } from '$lib/utils/ponto/processamento'; + import { registroFoiMarcado } from '$lib/utils/ponto/validacao'; + import { gerarPDFComSelecao } from '$lib/utils/ponto/pdf/geradorPDF'; + import { imprimirDetalhesRegistro } from '$lib/utils/ponto/pdf/geradorDetalhesPDF'; + import HeaderRegistroPontos from '$lib/components/ponto/registro-pontos/HeaderRegistroPontos.svelte'; + import EstatisticasCards from '$lib/components/ponto/registro-pontos/EstatisticasCards.svelte'; + import GraficoEstatisticas from '$lib/components/ponto/registro-pontos/GraficoEstatisticas.svelte'; + import { maskDate, validateDate, onlyDigits } from '$lib/utils/masks'; import LocalizacaoIcon from '$lib/components/ponto/LocalizacaoIcon.svelte'; import SaldoDiarioBadge from '$lib/components/ponto/SaldoDiarioBadge.svelte'; import SaldoDiarioComparativoBadge from '$lib/components/ponto/SaldoDiarioComparativoBadge.svelte'; @@ -28,18 +56,41 @@ import { Chart, registerables } from 'chart.js'; import Papa from 'papaparse'; - Chart.register(...registerables); + + + + + const client = useConvexClient(); + // ============================================ + // INTERFACES TYPESCRIPT + // ============================================ + // Tipos importados de $lib/utils/ponto/tipos + // Estados // Expandir período padrão para últimos 30 dias para facilitar visualização 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]!); + // Funções de formatação importadas de $lib/utils/ponto/formatacao + // Wrapper para formatarDataParaBackend que precisa de onlyDigits e validateDate + function formatarDataParaBackendWrapper(data: string): string { + return formatarDataParaBackend(data, onlyDigits, validateDate); + } + + let dataInicioInterno = $state(trintaDiasAtras.toISOString().split('T')[0]!); + let dataFimInterno = $state(hoje.toISOString().split('T')[0]!); + + // Valores para exibição (dd/mm/yyyy) + let dataInicioExibicao = $state(formatarDataParaExibicao(dataInicioInterno)); + let dataFimExibicao = $state(formatarDataParaExibicao(dataFimInterno)); + + // Valores para backend (yyyy-mm-dd) - derivados dos valores internos + const dataInicio = $derived(dataInicioInterno); + const dataFim = $derived(dataFimInterno); let funcionarioIdFiltro = $state | ''>(''); let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos'); let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos'); @@ -52,11 +103,12 @@ let chartInstance: Chart | null = null; // Parâmetros reativos para queries + // Nota: Apenas filtros de data são aplicados no backend para performance + // Filtros de funcionário, status e localização são aplicados no frontend const registrosParams = $derived({ - funcionarioId: - funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined, dataInicio, dataFim + // Removido funcionarioId para carregar todos os registros e filtrar no frontend }); const estatisticasParams = $derived({ dataInicio, @@ -241,6 +293,7 @@ // Também tentar criar quando o canvas for montado onMount(() => { + Chart.register(...registerables); // Tentar criar o gráfico após um pequeno delay para garantir que tudo está renderizado const timeoutId = setTimeout(() => { if (chartCanvas && estatisticas && chartData && !chartInstance) { @@ -264,18 +317,29 @@ }); // Filtrar registros com base nos filtros avançados - // Nota: Os filtros de data e funcionário são aplicados no backend através de registrosParams - // Os filtros de status e localização são aplicados aqui no frontend + // Nota: Os filtros de data são aplicados no backend através de registrosParams + // Os filtros de funcionário, status e localização são aplicados aqui no frontend para garantir funcionamento correto const registrosFiltrados = $derived.by(() => { if (!registros || registros.length === 0) return []; let resultado = [...registros]; + // Filtro de funcionário (aplicado no frontend para garantir que funcione corretamente) + if (funcionarioIdFiltro && funcionarioIdFiltro !== '') { + resultado = resultado.filter((r) => { + return r.funcionarioId === funcionarioIdFiltro; + }); + } + // Filtro de status (Dentro/Fora do Prazo) if (statusFiltro !== 'todos') { resultado = resultado.filter((r) => { - if (statusFiltro === 'dentro') return r.dentroDoPrazo === true; - if (statusFiltro === 'fora') return r.dentroDoPrazo === false; + if (statusFiltro === 'dentro') { + return r.dentroDoPrazo === true; + } + if (statusFiltro === 'fora') { + return r.dentroDoPrazo === false; + } return true; }); } @@ -287,8 +351,12 @@ if (r.dentroRaioPermitido === undefined || r.dentroRaioPermitido === null) { return false; } - if (localizacaoFiltro === 'dentro') return r.dentroRaioPermitido === true; - if (localizacaoFiltro === 'fora') return r.dentroRaioPermitido === false; + if (localizacaoFiltro === 'dentro') { + return r.dentroRaioPermitido === true; + } + if (localizacaoFiltro === 'fora') { + return r.dentroRaioPermitido === false; + } return true; }); } @@ -296,8 +364,14 @@ return resultado; }); - // Agrupar registros por funcionário e data + // Agrupar registros por funcionário e data usando função extraída const registrosAgrupados = $derived.by(() => { + return agruparRegistrosPorFuncionario(registrosFiltrados, config || undefined); + }); + + // Código antigo de agrupamento (comentado para referência) + /* + const registrosAgrupadosOld = $derived.by(() => { const configData = config; const agrupados: Record< string, @@ -502,25 +576,7 @@ return registrosAgrupados.map((grupo) => grupo.funcionarioId); }); - // Função para formatar saldo de horas - function formatarSaldoHoras(minutos: number): string { - const horas = Math.floor(Math.abs(minutos) / 60); - const mins = Math.abs(minutos) % 60; - const sinal = minutos >= 0 ? '+' : '-'; - return `${sinal}${horas}h ${mins}min`; - } - - // Função para formatar saldo diário - 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`; - } + // Funções de formatação importadas de $lib/utils/ponto/formatacao // Usar função centralizada formatarDataDDMMAAAA da lib/utils/ponto.ts @@ -530,418 +586,512 @@ return funcionarios.find((f) => f._id === funcionarioIdFiltro)?.nome || null; }); - // Função para calcular saldo diário como diferença entre saída e entrada - /** - * 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< - 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; + // Funções de cálculo importadas de $lib/utils/ponto/calculos + // calcularSaldosParciais, calcularSaldoDiario, calcularSaldosPorPar, calcularSaldoComparativoPorPar - // Criar array com índices originais - const registrosComIndice = registros.map((r, idx) => ({ ...r, originalIndex: idx })); + // Funções importadas de $lib/utils/ponto/processamento e $lib/utils/ponto/validacao + // gerarDiasPeriodo, gerarRegistrosEsperados, registroFoiMarcado - // Ordenar registros por hora e minuto para processar em ordem cronológica - const registrosOrdenados = [...registrosComIndice].sort((a, b) => { - if (a.hora !== b.hora) { - return a.hora - b.hora; - } - return a.minuto - b.minuto; - }); - - // Identificar pares entrada/saída - // Par 1: entrada -> saida_almoco - // Par 2: retorno_almoco -> saida - 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; - } else if (entradaAtual) { - // Qualquer saída (saida_almoco ou saida) fecha o período atual - if (registro.tipo === 'saida_almoco' || registro.tipo === 'saida') { - // 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 - } - - const horas = Math.floor(saldoMinutos / 60); - const minutos = saldoMinutos % 60; - - // Salvar saldo no índice original do registro de saída - saldos.set(registro.originalIndex, { - saldoMinutos, - horas, - minutos, - positivo: true, - parNumero - }); - - entradaAtual = null; // Resetar para próximo par - parNumero++; - } - } - } - - return saldos; - } - - 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 - const registrosOrdenados = [...registros].sort((a, b) => { - if (a.hora !== b.hora) { - return a.hora - b.hora; - } - return a.minuto - b.minuto; - }); - - // Buscar entrada (primeiro registro do tipo 'entrada') - const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); - // Buscar saída (último registro do tipo 'saida') - const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop(); - - if (!entrada || !saida) return null; - - // 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) { - saldoMinutos += 24 * 60; // Adicionar um dia em minutos - } - - const horas = Math.floor(saldoMinutos / 60); - const minutos = saldoMinutos % 60; - - return { - saldoMinutos, - horas, - minutos, - positivo: true // Sempre positivo, pois é tempo trabalhado - }; - } - - /** - * 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< - 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 - const registrosOrdenados = [...registros].sort((a, b) => { - if (a.hora !== b.hora) { - return a.hora - b.hora; - } - return a.minuto - b.minuto; - }); - - let parIndex = 0; - let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null; - let indicesPar: number[] = []; - - 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 - if (entradaAtual && indicesPar.length > 0) { - indicesPar = []; - } - entradaAtual = { ...reg, index: i }; - indicesPar = [i]; - } - // 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 - } - - const horas = Math.floor(saldoMinutos / 60); - const minutos = saldoMinutos % 60; - - // Associar saldo a todos os registros do par - for (const idx of indicesPar) { - saldos.set(idx, { - saldoMinutos, - horas, - minutos, - parIndex, - tamanhoPar: indicesPar.length - }); - } - - parIndex++; - entradaAtual = null; - indicesPar = []; - } - } - - return saldos; - } - - /** - * Calcula saldos comparativos por par entrada/saída - * Compara horários reais com horários esperados configurados - * Retorna mapa com saldo trabalhado, esperado e diferença - */ - function calcularSaldoComparativoPorPar( - registros: Array<{ tipo: string; hora: number; minuto: number }>, - config: { - horarioEntrada: string; - horarioSaidaAlmoco: string; - horarioRetornoAlmoco: string; - horarioSaida: string; - } - ): Map< - number, - { - trabalhadoMinutos: number; - trabalhadoHoras: number; - trabalhadoMinutosResto: number; - esperadoMinutos: number; - esperadoHoras: number; - esperadoMinutosResto: number; - diferencaMinutos: number; - diferencaHoras: number; - diferencaMinutosResto: number; - parIndex: number; - tamanhoPar: number; - } - > { - 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 [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida.split(':').map(Number); - - // Ordenar registros por hora e minuto - const registrosOrdenados = [...registros].sort((a, b) => { - if (a.hora !== b.hora) { - return a.hora - b.hora; - } - return a.minuto - b.minuto; - }); - - let parIndex = 0; - let entradaAtual: { tipo: string; hora: number; minuto: number; index: number } | null = null; - let indicesPar: number[] = []; - - 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 - if (entradaAtual && indicesPar.length > 0) { - indicesPar = []; - } - entradaAtual = { ...reg, index: i }; - indicesPar = [i]; - } - // Identificar fim de um par (saida_almoco ou saida) - else if ((reg.tipo === 'saida_almoco' || reg.tipo === 'saida') && entradaAtual) { - indicesPar.push(i); - - // Calcular tempo trabalhado real (saída - entrada) - const minutosEntradaReal = entradaAtual.hora * 60 + entradaAtual.minuto; - const minutosSaidaReal = reg.hora * 60 + reg.minuto; - let trabalhadoMinutos = minutosSaidaReal - minutosEntradaReal; - if (trabalhadoMinutos < 0) { - trabalhadoMinutos += 24 * 60; - } - - // Calcular tempo esperado baseado no tipo de par - let esperadoMinutos: number; - if (entradaAtual.tipo === 'entrada') { - // Par 1: entrada -> saida_almoco - const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado; - const minutosSaidaEsperada = horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado; - esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada; - if (esperadoMinutos < 0) { - esperadoMinutos += 24 * 60; - } - } else { - // Par 2: retorno_almoco -> saida - const minutosEntradaEsperada = - horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado; - const minutosSaidaEsperada = horaSaidaEsperada * 60 + minutoSaidaEsperado; - esperadoMinutos = minutosSaidaEsperada - minutosEntradaEsperada; - if (esperadoMinutos < 0) { - esperadoMinutos += 24 * 60; - } - } - - // Calcular diferença (trabalhado - esperado) - const diferencaMinutos = trabalhadoMinutos - esperadoMinutos; - - // Converter para horas e minutos - const trabalhadoHoras = Math.floor(trabalhadoMinutos / 60); - const trabalhadoMinutosResto = trabalhadoMinutos % 60; - - const esperadoHoras = Math.floor(esperadoMinutos / 60); - const esperadoMinutosResto = esperadoMinutos % 60; - - const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60); - const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60; - - // Associar saldo a todos os registros do par - for (const idx of indicesPar) { - saldos.set(idx, { - trabalhadoMinutos, - trabalhadoHoras, - trabalhadoMinutosResto, - esperadoMinutos, - esperadoHoras, - esperadoMinutosResto, - diferencaMinutos, - diferencaHoras, - diferencaMinutosResto, - parIndex, - tamanhoPar: indicesPar.length - }); - } - - parIndex++; - entradaAtual = null; - indicesPar = []; - } - } - - return saldos; - } - - /** - * Gera array de todas as datas do período selecionado - */ - function gerarDiasPeriodo(dataInicio: string, dataFim: string): string[] { - 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 }> { - 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 [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 } - ]; - } - - /** - * Verifica se um registro esperado foi marcado - */ - function registroFoiMarcado( - registroEsperado: { tipo: string; hora: number; minuto: number }, - registrosReais: Array<{ tipo: string; hora: number; minuto: number }> - ): boolean { - return registrosReais.some((r) => r.tipo === registroEsperado.tipo); - } - - function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) { + const abrirModalImpressao = (funcionarioId: Id<'funcionarios'>) => { funcionarioParaImprimir = funcionarioId; mostrarModalImpressao = true; + }; + + // ============================================ + // FUNÇÕES AUXILIARES DE FORMATAÇÃO + // ============================================ + + // Funções formatarMinutos e formatarHoras importadas de $lib/utils/ponto/formatacao + + /** + * Obter nome do dia da semana em português + */ + function obterDiaSemana(data: string): string { + const dias = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; + const dataObj = new Date(data + 'T00:00:00'); + return dias[dataObj.getDay()] || ''; } + /** + * Obter cor de fundo baseado no tipo de dia + */ + function obterCorFundo(tipoDia: TipoDia, temInconsistencia: boolean): string { + if (temInconsistencia) return '#FFF3E0'; // Laranja claro + if (tipoDia === 'atestado') return '#E3F2FD'; // Azul claro + if (tipoDia === 'ausencia') return '#FFF9C4'; // Amarelo claro + if (tipoDia === 'abonado') return '#E8F5E9'; // Verde claro + if (tipoDia === 'nao_computado' || tipoDia === 'ferias') return '#F5F5F5'; // Cinza claro + return '#FFFFFF'; // Branco + } + + /** + * Obter ícone baseado no tipo de dia + */ + function obterIconeTipo(tipoDia: TipoDia): string { + const icones: Record = { + normal: '', + atestado: '🏥', + ausencia: '🚫', + licenca: '📋', + abonado: '✅', + nao_computado: '⏸', + ferias: '🏖', + inconsistente: '⚠' + }; + return icones[tipoDia] || ''; + } + + // Função validarPeriodo importada de $lib/utils/ponto/validacao + + // ============================================ + // FUNÇÃO DE PROCESSAMENTO DE DADOS PARA FICHA + // ============================================ + // Função processarDadosFichaPonto importada de $lib/utils/ponto/processamento + // A função abaixo foi movida para o módulo e está comentada para referência + /* + async function processarDadosFichaPontoOld( + funcionarioId: Id<'funcionarios'>, + dataInicio: string, + dataFim: string + ): Promise<{ + dias: DiaFichaPonto[]; + resumo: ResumoPeriodo; + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + }; + }> { + // Buscar todos os dados necessários + const [ + registrosFuncionario, + atestadosLicencas, + ausenciasTodas, + ajustes, + inconsistencias, + homologacoes, + dispensas, + config + ] = await Promise.all([ + client.query(api.pontos.listarRegistrosPeriodo, { + funcionarioId, + dataInicio, + dataFim + }), + client.query(api.atestadosLicencas.listarPorFuncionario, { + funcionarioId + }), + client.query(api.ausencias.listarTodas, {}), + client.query(api.pontos.listarAjustesBancoHoras, { + funcionarioId + }), + client.query(api.pontos.listarInconsistenciasBancoHoras, {}), + client.query(api.pontos.listarHomologacoes, { + funcionarioId + }), + client.query(api.pontos.listarDispensas, { + funcionarioId, + apenasAtivas: false + }), + client.query(api.configuracaoPonto.obterConfiguracao, {}) + ]); + + const atestados = atestadosLicencas?.atestados || []; + const licencas = atestadosLicencas?.licencas || []; + const ausencias = (ausenciasTodas || []).filter((a) => a.funcionarioId === funcionarioId); + + if (!config) { + throw new Error('Configuração de ponto não encontrada'); + } + + // Filtrar dados pelo período + const dataInicioObj = new Date(dataInicio + 'T00:00:00'); + const dataFimObj = new Date(dataFim + 'T23:59:59'); + + const atestadosPeriodo = (atestados || []).filter((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return inicio <= dataFimObj && fim >= dataInicioObj; + }); + + const ausenciasPeriodo = (ausencias || []).filter((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return inicio <= dataFimObj && fim >= dataInicioObj; + }); + + const licencasPeriodo = (licencas || []).filter((l) => { + const inicio = new Date(l.dataInicio); + const fim = new Date(l.dataFim); + return inicio <= dataFimObj && fim >= dataInicioObj; + }); + + const ajustesPeriodo = (ajustes || []).filter((a) => { + const dataAjuste = new Date(a.dataAplicacao); + return dataAjuste >= dataInicioObj && dataAjuste <= dataFimObj; + }); + + const inconsistenciasPeriodo = (inconsistencias || []).filter((i) => { + if (i.funcionarioId !== funcionarioId) return false; + const dataInconsistencia = new Date(i.dataDetectada); + return dataInconsistencia >= dataInicioObj && dataInconsistencia <= dataFimObj; + }); + + const dataInicioTimestamp = dataInicioObj.getTime(); + const dataFimTimestamp = dataFimObj.getTime(); + const homologacoesPeriodo = (homologacoes || []).filter((h) => { + return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp; + }); + + const dispensasPeriodo = (dispensas || []).filter((d) => { + const dispensaInicio = new Date(d.dataInicio + 'T00:00:00'); + const dispensaFim = new Date(d.dataFim + 'T23:59:59'); + return dispensaInicio <= dataFimObj && dispensaFim >= dataInicioObj; + }); + + // Gerar todos os dias do período + const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim); + const diasProcessados: DiaFichaPonto[] = []; + + // Agrupar registros por data + const registrosPorData: Record = {}; + for (const r of registrosFuncionario || []) { + if (!registrosPorData[r.data]) { + registrosPorData[r.data] = []; + } + registrosPorData[r.data]!.push(r); + } + + // Processar cada dia + for (const data of diasPeriodo) { + const dataObj = new Date(data); + const regsReais = registrosPorData[data] || []; + const regsEsperados = gerarRegistrosEsperados(data, config); + + // Verificar atestado + const atestadoDia = + atestadosPeriodo.find((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return dataObj >= inicio && dataObj <= fim; + }) || null; + + // Verificar ausência + const ausenciaDia = + ausenciasPeriodo.find((a) => { + const inicio = new Date(a.dataInicio); + const fim = new Date(a.dataFim); + return dataObj >= inicio && dataObj <= fim; + }) || null; + + // Verificar licença + const licencaDia = + licencasPeriodo.find((l) => { + const inicio = new Date(l.dataInicio); + const fim = new Date(l.dataFim); + return dataObj >= inicio && dataObj <= fim; + }) || null; + + // Verificar ajustes do dia + const ajustesDia = ajustesPeriodo.filter((a) => a.dataAplicacao === data); + + // Verificar inconsistências do dia + const inconsistenciasDia = inconsistenciasPeriodo.filter((i) => i.dataDetectada === data); + + // Verificar homologações do dia + const homologacoesDia = homologacoesPeriodo.filter((h) => { + if (h.registroId) { + const registro = regsReais.find((r) => r._id === h.registroId); + return registro !== undefined; + } + return false; + }); + + // Verificar dispensa + const dispensaDia = + dispensasPeriodo.find((d) => { + const dispensaInicio = new Date(d.dataInicio + 'T00:00:00'); + const dispensaFim = new Date(d.dataFim + 'T23:59:59'); + return dataObj >= dispensaInicio && dataObj <= dispensaFim; + }) || null; + + // Calcular saldo diário + const regsReaisOrdenados = [...regsReais].sort((a, b) => { + if (a.hora !== b.hora) return a.hora - b.hora; + return a.minuto - b.minuto; + }); + const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config); + + let saldoDiario: SaldoDiario | null = null; + let saldoDiarioTotalDiferencaMinutos = 0; + let saldoDiarioTotalTrabalhadoMinutos = 0; + let saldoDiarioTotalEsperadoMinutos = 0; + + // Somar saldos dos pares + const paresProcessados = new Set(); + for (const [, saldo] of saldosComparativosPorPar.entries()) { + if (!paresProcessados.has(saldo.parIndex)) { + saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos; + saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos; + saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos; + paresProcessados.add(saldo.parIndex); + } + } + + // Calcular saldo para pares não marcados + const todosRegistros: Array<{ tipo: string; hora: number; minuto: number; real: boolean }> = + []; + for (const reg of regsReais) { + todosRegistros.push({ + tipo: reg.tipo, + hora: reg.hora, + minuto: reg.minuto, + real: true + }); + } + for (const regEsperado of regsEsperados) { + if (!registroFoiMarcado(regEsperado, regsReais)) { + todosRegistros.push({ + tipo: regEsperado.tipo, + hora: regEsperado.hora, + minuto: regEsperado.minuto, + real: false + }); + } + } + + // Identificar pares não marcados e calcular saldo negativo + for (let i = 0; i < todosRegistros.length; i++) { + const reg = todosRegistros[i]; + if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) { + const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida'; + const saidaEsperada = todosRegistros.find((r, idx) => { + if (idx <= i) return false; + if (r.tipo !== tipoSaidaEsperado || r.real) return false; + const minutosEntrada = reg.hora * 60 + reg.minuto; + const minutosSaidaEsperada = r.hora * 60 + r.minuto; + const temRegistroRealNoIntervalo = regsReais.some((real) => { + if (real.tipo !== tipoSaidaEsperado) return false; + const minutosReal = real.hora * 60 + real.minuto; + return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada; + }); + return !temRegistroRealNoIntervalo; + }); + + if (saidaEsperada) { + let esperadoMinutos: number; + if (reg.tipo === 'entrada') { + 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; + 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 minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado; + esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada; + if (esperadoMinutos < 0) esperadoMinutos += 24 * 60; + } + + saldoDiarioTotalDiferencaMinutos -= esperadoMinutos; + saldoDiarioTotalEsperadoMinutos += esperadoMinutos; + } + } + } + + // Aplicar ajustes manuais + for (const ajuste of ajustesDia) { + if (ajuste.tipo === 'abonar') { + saldoDiarioTotalDiferencaMinutos += ajuste.valorMinutos; + } else if (ajuste.tipo === 'descontar') { + saldoDiarioTotalDiferencaMinutos -= ajuste.valorMinutos; + } + } + + // Calcular diferença final + const diferencaDiariaCorrigida = + saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos; + + saldoDiario = { + diferencaMinutos: diferencaDiariaCorrigida, + trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos, + esperadoMinutos: saldoDiarioTotalEsperadoMinutos + }; + + // Determinar tipo de dia + let tipoDia: TipoDia = 'normal'; + let computado = true; + + if (dispensaDia) { + tipoDia = 'nao_computado'; + computado = false; + } else if (licencaDia) { + tipoDia = 'licenca'; + computado = false; + } else if (atestadoDia) { + tipoDia = 'atestado'; + computado = false; + } else if (ausenciaDia) { + tipoDia = 'ausencia'; + computado = false; + } else if (ajustesDia.some((a) => a.tipo === 'abonar' && a.valorMinutos >= 240)) { + tipoDia = 'abonado'; + } + + if (inconsistenciasDia.length > 0) { + tipoDia = 'inconsistente'; + } + + diasProcessados.push({ + data, + dataFormatada: formatarDataDDMMAAAA(data), + tipoDia, + registros: regsReais, + registrosEsperados: regsEsperados, + saldoDiario, + saldoAcumulado: 0, // Será calculado depois + atestado: atestadoDia + ? { + _id: atestadoDia._id, + tipo: atestadoDia.tipo, + dataInicio: atestadoDia.dataInicio, + dataFim: atestadoDia.dataFim, + motivo: atestadoDia.observacoes + } + : null, + ausencia: ausenciaDia + ? { + _id: ausenciaDia._id, + motivo: ausenciaDia.motivo, + dataInicio: ausenciaDia.dataInicio, + dataFim: ausenciaDia.dataFim, + status: ausenciaDia.status + } + : null, + licenca: licencaDia + ? { + _id: licencaDia._id, + tipo: licencaDia.tipo || 'licenca', + dataInicio: licencaDia.dataInicio, + dataFim: licencaDia.dataFim + } + : null, + ajustes: ajustesDia.map((a) => ({ + _id: a._id, + tipo: a.tipo, + valorMinutos: a.valorMinutos, + motivoDescricao: a.motivoDescricao, + gestorId: a.gestorId + })), + inconsistencias: inconsistenciasDia.map((i) => ({ + _id: i._id, + tipo: i.tipo, + descricao: i.descricao, + dataDetectada: i.dataDetectada, + status: i.status, + resolvidoPor: i.resolvidoPor, + resolvidoEm: i.resolvidoEm + })), + homologacoes: homologacoesDia.map((h) => ({ + _id: h._id, + motivoDescricao: h.motivoDescricao, + gestorId: h.gestorId + })), + dispensa: dispensaDia + ? { + _id: dispensaDia._id, + motivo: dispensaDia.motivo, + dataInicio: dispensaDia.dataInicio, + dataFim: dispensaDia.dataFim, + ativo: dispensaDia.ativo + } + : null, + computado + }); + } + + // Calcular saldo acumulado para cada dia + // TODO: Buscar saldo inicial do backend se necessário + let saldoAcumulado = 0; // Saldo inicial (será 0 por enquanto, pode ser buscado do backend) + + for (const dia of diasProcessados) { + if (dia.computado && dia.saldoDiario) { + saldoAcumulado += dia.saldoDiario.diferencaMinutos; + } + dia.saldoAcumulado = saldoAcumulado; + } + + // Calcular resumo com formatações + const totalHorasTrabalhadas = diasProcessados + .filter((d) => d.computado) + .reduce((acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0), 0); + const totalHorasEsperadas = diasProcessados + .filter((d) => d.computado) + .reduce((acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0), 0); + const diferencaTotal = diasProcessados + .filter((d) => d.computado) + .reduce((acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0), 0); + const saldoPeriodo = diferencaTotal; + const saldoFinal = diasProcessados.length > 0 ? diasProcessados[diasProcessados.length - 1]!.saldoAcumulado : 0; + + const resumo: ResumoPeriodo = { + totalDias: diasProcessados.length, + diasTrabalhados: diasProcessados.filter((d) => d.computado && d.registros.length > 0).length, + diasComAtestado: diasProcessados.filter((d) => d.atestado !== null).length, + diasAusentes: diasProcessados.filter((d) => d.ausencia !== null).length, + diasComLicenca: diasProcessados.filter((d) => d.licenca !== null).length, + diasAbonados: diasProcessados.filter((d) => d.tipoDia === 'abonado').length, + diasNaoComputados: diasProcessados.filter((d) => !d.computado).length, + diasComInconsistencia: diasProcessados.filter((d) => d.inconsistencias.length > 0).length, + totalHorasTrabalhadas, + totalHorasEsperadas, + diferencaTotal, + saldoInicial: 0, // TODO: Buscar do backend + saldoFinal, + saldoPeriodo, + totalInconsistencias: inconsistenciasPeriodo.length, + saldoInicialFormatado: formatarMinutos(0), + saldoPeriodoFormatado: formatarMinutos(saldoPeriodo), + saldoFinalFormatado: formatarMinutos(saldoFinal), + totalHorasTrabalhadasFormatado: formatarHoras(totalHorasTrabalhadas), + totalHorasEsperadasFormatado: formatarHoras(totalHorasEsperadas), + diferencaTotalFormatado: formatarMinutos(diferencaTotal) + }; + + return { + dias: diasProcessados, + resumo, + config + }; + } + */ + // Função para limpar todos os filtros function limparFiltros() { 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]!; + const dataInicioStr = trintaDiasAtras.toISOString().split('T')[0]!; + const dataFimStr = hoje.toISOString().split('T')[0]!; + + dataInicioInterno = dataInicioStr; + dataFimInterno = dataFimStr; + dataInicioExibicao = formatarDataParaExibicao(dataInicioStr); + dataFimExibicao = formatarDataParaExibicao(dataFimStr); funcionarioIdFiltro = ''; statusFiltro = 'todos'; localizacaoFiltro = 'todos'; @@ -1026,14 +1176,33 @@ } } - async function gerarPDFComSelecao(sections: { - dadosFuncionario: boolean; - registrosPonto: boolean; - saldoDiario: boolean; - bancoHoras: boolean; - alteracoesGestor: boolean; - dispensasRegistro: boolean; - }) { + // Wrapper para a função importada gerarPDFComSelecao + async function gerarPDFComSelecaoWrapper(sections: SectionsPDF) { + if (!funcionarioParaImprimir) return; + + const funcionarioId = funcionarioParaImprimir; + + await gerarPDFComSelecao( + client, + sections, + funcionarioId, + dataInicio, + dataFim, + funcionarios, + logoGovPE, + (message: string) => toast.error(message), + () => { + mostrarModalImpressao = false; + funcionarioParaImprimir = ''; + toast.success('PDF gerado com sucesso!'); + }, + (value: boolean) => { carregando = value; } + ); + } + + // Função antiga comentada (foi movida para módulo) + /* + async function gerarPDFComSelecaoOld(sections: SectionsPDF) { if (!funcionarioParaImprimir) return; const funcionarioId = funcionarioParaImprimir; @@ -1044,225 +1213,134 @@ return; } + // Validar período + const validacaoPeriodo = validarPeriodo(dataInicio, dataFim); + if (!validacaoPeriodo.valido) { + toast.error(validacaoPeriodo.erro || 'Período inválido'); + return; + } + const funcionario = funcionarios.find((f) => f._id === funcionarioId); if (!funcionario) { toast.error('Funcionário não encontrado'); return; } - // Buscar registros do funcionário no período selecionado - const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, { - funcionarioId, - dataInicio, - dataFim - }); - - if (!registrosFuncionario || registrosFuncionario.length === 0) { - toast.error('Nenhum registro encontrado para este funcionário no período selecionado'); - return; - } - try { - const doc = new jsPDF(); + // Início do bloco try para geração de PDF + carregando = true; + // Processar todos os dados necessários + const { + dias, + resumo, + config: configPonto + } = await processarDadosFichaPonto(client, funcionarioId, dataInicio, dataFim); - // 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 = 25; - const aspectRatio = logoImg.height / logoImg.width; - const logoHeight = logoWidth * aspectRatio; - - doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight); - yPosition = Math.max(20, 10 + logoHeight / 2); - } catch (err) { - console.warn('Não foi possível carregar a logo:', err); + if (dias.length === 0) { + toast.error('Nenhum dado encontrado para este funcionário no período selecionado'); + carregando = false; + return; } - // Cabeçalho - doc.setFontSize(16); - doc.setTextColor(41, 128, 185); - doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' }); + const doc = new jsPDF(); - yPosition += 10; + // Logo e cabeçalho + let yPosition = await adicionarLogo(doc, logoGovPE); + yPosition = adicionarCabecalho(doc, yPosition); // Dados do Funcionário if (sections.dadosFuncionario) { - 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); - - if (funcionario.matricula) { - doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition); - yPosition += 6; - } - doc.text(`Nome: ${funcionario.nome}`, 15, yPosition); - yPosition += 6; - if (funcionario.descricaoCargo) { - doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition); - yPosition += 6; - } - - yPosition += 5; - // Formatar período para exibição usando função centralizada - const periodoFormatado = `${formatarDataDDMMAAAA(dataInicio)} a ${formatarDataDDMMAAAA(dataFim)}`; - doc.text(`Período: ${periodoFormatado}`, 15, yPosition); - yPosition += 10; + yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim); } - // Buscar homologações e dispensas - let homologacoes: Array<{ - _id: Id<'homologacoesPonto'>; - criadoEm: number; - registroId?: Id<'registrosPonto'>; - horaAnterior?: number; - minutoAnterior?: number; - horaNova?: number; - minutoNova?: number; - tipoAjuste?: 'compensar' | 'abonar' | 'descontar'; - periodoDias?: number; - periodoHoras?: number; - periodoMinutos?: number; - motivoDescricao?: string; - motivoTipo?: string; - observacoes?: string; - }> = []; + // Resumo do Período + yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos); - let dispensas: Array<{ - dataInicio: string; - dataFim: string; - horaInicio: number; - minutoInicio: number; - horaFim: number; - minutoFim: number; - motivo: string; - isento: boolean; - }> = []; + // Saldos do Período + yPosition = adicionarSaldosPeriodo(doc, yPosition, resumo, formatarMinutos); - if (sections.alteracoesGestor) { - try { - const todasHomologacoes = - (await client.query(api.pontos.listarHomologacoes, { - funcionarioId - })) || []; + // Legenda + yPosition = adicionarLegenda(doc, yPosition); - // 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; - }); - } catch (error) { - console.warn('Erro ao buscar homologações:', error); - // Continuar mesmo se houver erro ao buscar homologações - } - } - - if (sections.dispensasRegistro) { - try { - 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; - }); - } catch (error) { - console.warn('Erro ao buscar dispensas:', error); - // Continuar mesmo se houver erro ao buscar dispensas - } - } - - // Variável para armazenar saldos diários (usada no resumo do banco de horas) - const saldosDiariosPorData: Record< - string, - { - diferencaMinutos: number; - trabalhadoMinutos: number; - esperadoMinutos: number; - } - > = {}; - - // Tabela de registros + // ============================================ + // SEÇÃO: TABELA PRINCIPAL DE REGISTROS + // ============================================ if (sections.registrosPonto) { - const config = await client.query(api.configuracaoPonto.obterConfiguracao, {}); - if (!config) { - throw new Error('Configuração de ponto não encontrada'); + if (yPosition > 250) { + doc.addPage(); + yPosition = 20; } - const tableData: any[][] = []; + doc.setFontSize(14); + doc.setTextColor(41, 128, 185); + doc.setFont('helvetica', 'bold'); + doc.text('REGISTROS DE PONTO', 15, yPosition); + yPosition += 10; - // Agrupar registros reais por data - const registrosPorData: Record< - string, - Array<{ - data: string; - tipo: string; - hora: number; - minuto: number; - dentroDoPrazo: boolean; - dentroRaioPermitido: boolean | null | undefined; - acelerometroX?: number | undefined; - acelerometroY?: number | undefined; - acelerometroZ?: number | undefined; - movimentoDetectado?: boolean | undefined; - magnitudeMovimento?: number | undefined; - sensorDisponivel?: boolean | undefined; - }> - > = {}; - - for (const r of registrosFuncionario) { - const dataKey = r.data; - if (!registrosPorData[dataKey]) { - registrosPorData[dataKey] = []; + // Função auxiliar para obter cor de fundo baseada no tipo de dia + const obterCorFundoTipoDia = (tipoDia: TipoDia): number[] => { + switch (tipoDia) { + case 'atestado': + return [230, 240, 255]; // Azul claro + case 'ausencia': + return [255, 255, 230]; // Amarelo claro + case 'abonado': + return [230, 255, 230]; // Verde claro + case 'nao_computado': + return [240, 240, 240]; // Cinza claro + case 'inconsistente': + return [255, 240, 230]; // Laranja claro + default: + return [255, 255, 255]; // Branco } - registrosPorData[dataKey]!.push({ - data: r.data, - tipo: r.tipo, - hora: r.hora, - minuto: r.minuto, - dentroDoPrazo: r.dentroDoPrazo, - dentroRaioPermitido: r.dentroRaioPermitido, - acelerometroX: r.acelerometroX, - acelerometroY: r.acelerometroY, - acelerometroZ: r.acelerometroZ, - movimentoDetectado: r.movimentoDetectado, - magnitudeMovimento: r.magnitudeMovimento, - sensorDisponivel: r.sensorDisponivel - }); - } + }; - // Gerar todos os dias do período - const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim); + // Função auxiliar para obter ícone do tipo de dia + const obterIconeTipoDia = (dia: DiaFichaPonto): string => { + if (dia.atestado) return '🏥'; + if (dia.ausencia) return '🚫'; + if (dia.licenca) return '📋'; + if (dia.tipoDia === 'abonado') return '✅'; + if (dia.tipoDia === 'nao_computado') return '⏸'; + if (dia.inconsistencias.length > 0) return '⚠'; + return ''; + }; - // Processar cada dia do período - for (const data of diasPeriodo) { - const dataFormatada = formatarDataDDMMAAAA(data); - const regsReais = registrosPorData[data] || []; - const regsEsperados = gerarRegistrosEsperados(data, config); + // Função auxiliar para obter texto do tipo de dia + const obterTextoTipoDia = (dia: DiaFichaPonto): string => { + if (dia.atestado) return 'Atestado'; + if (dia.ausencia) return 'Ausência'; + if (dia.licenca) return 'Licença'; + if (dia.tipoDia === 'abonado') return 'Abonado'; + if (dia.tipoDia === 'nao_computado') return 'Não Computado'; + if (dia.inconsistencias.length > 0) return 'Inconsistente'; + return 'Normal'; + }; + + // Função auxiliar para formatar saldo + const formatarSaldo = (saldo: SaldoDiario | null): string => { + if (!saldo) return '-'; + const horas = Math.floor(Math.abs(saldo.diferencaMinutos) / 60); + const minutos = Math.abs(saldo.diferencaMinutos) % 60; + const sinal = saldo.diferencaMinutos >= 0 ? '+' : '-'; + return `${sinal}${horas}h ${minutos}min`; + }; + + const tableData: Array< + Array< + | string + | { + content: string; + styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string }; + } + > + > = []; + + // Processar cada dia usando os dados já processados + for (const dia of dias) { + const corFundo = obterCorFundoTipoDia(dia.tipoDia); + const iconeTipoDia = obterIconeTipoDia(dia); + const textoTipoDia = obterTextoTipoDia(dia); // Combinar registros reais e esperados const todosRegistros: Array<{ @@ -1274,7 +1352,7 @@ }> = []; // Adicionar registros reais - for (const reg of regsReais) { + for (const reg of dia.registros) { todosRegistros.push({ tipo: reg.tipo, hora: reg.hora, @@ -1284,9 +1362,9 @@ }); } - // Adicionar registros esperados não marcados (em vermelho) - for (const regEsperado of regsEsperados) { - if (!registroFoiMarcado(regEsperado, regsReais)) { + // Adicionar registros esperados não marcados + for (const regEsperado of dia.registrosEsperados) { + if (!registroFoiMarcado(regEsperado, dia.registros)) { todosRegistros.push({ tipo: regEsperado.tipo, hora: regEsperado.hora, @@ -1296,43 +1374,200 @@ } } - // Ordenar todos os registros por hora e minuto + // Ordenar registros por hora e minuto todosRegistros.sort((a, b) => { - if (a.hora !== b.hora) { - return a.hora - b.hora; - } - return a.minuto - b.minuto; - }); - - // Calcular saldos comparativos por par entrada/saída (apenas com registros reais) - const regsReaisOrdenados = [...regsReais].sort((a, b) => { if (a.hora !== b.hora) return a.hora - b.hora; return a.minuto - b.minuto; }); - const saldosComparativosPorPar = calcularSaldoComparativoPorPar( - regsReaisOrdenados, - config - ); - // Calcular saldos esperados para pares incompletos ou dias sem registros - 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; + // Criar linhas da tabela para cada registro + for (let i = 0; i < todosRegistros.length; i++) { + const reg = todosRegistros[i]; + const linha: Array< + | string + | { + content: string; + styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string }; + } + > = []; + + // Coluna Data (apenas na primeira linha do dia) + if (i === 0) { + linha.push({ + content: dia.dataFormatada, + styles: { fillColor: corFundo, fontStyle: 'bold' } + }); + } else { + linha.push(''); } - > = new Map(); - // Calcular saldo diário total (diferença acumulada de todos os pares) + // Coluna Tipo de Dia (apenas na primeira linha) + if (i === 0) { + linha.push({ + content: `${iconeTipoDia} ${textoTipoDia}`, + styles: { fillColor: corFundo } + }); + } else { + linha.push(''); + } + + // Coluna Tipo de Registro + const tipoLabel = configPonto + ? getTipoRegistroLabel( + reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', + { + nomeEntrada: configPonto.nomeEntrada, + nomeSaidaAlmoco: configPonto.nomeSaidaAlmoco, + nomeRetornoAlmoco: configPonto.nomeRetornoAlmoco, + nomeSaida: configPonto.nomeSaida + } + ) + : getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'); + + linha.push({ + content: `${reg.real ? '✓' : '✗'} ${tipoLabel}`, + styles: { + textColor: reg.real ? [0, 128, 0] : [200, 0, 0], // Verde se marcado, vermelho se não + fillColor: corFundo + } + }); + + // Coluna Horário + linha.push({ + content: formatarHoraPonto(reg.hora, reg.minuto), + styles: { + textColor: reg.real ? [0, 0, 0] : [200, 0, 0], // Preto se marcado, vermelho se não + fillColor: corFundo + } + }); + + // Coluna Saldo Diário (apenas na primeira linha do dia, se houver saldo) + if (sections.saldoDiario) { + if (i === 0 && dia.saldoDiario) { + const saldoFormatado = formatarSaldo(dia.saldoDiario); + const saldoPositivo = dia.saldoDiario.diferencaMinutos > 0; + const saldoNegativo = dia.saldoDiario.diferencaMinutos < 0; + + linha.push({ + content: saldoFormatado, + styles: { + textColor: saldoPositivo ? [0, 128, 0] : saldoNegativo ? [200, 0, 0] : [0, 0, 0], + fontStyle: saldoPositivo || saldoNegativo ? 'bold' : 'normal', + fillColor: corFundo + } + }); + } else { + linha.push(''); + } + } + + // Coluna Observações (apenas na primeira linha) + if (i === 0) { + const observacoes: string[] = []; + if (dia.atestado) { + observacoes.push(`Atestado: ${dia.atestado.motivo}`); + } + if (dia.ausencia) { + observacoes.push(`Ausência: ${dia.ausencia.motivo}`); + } + if (dia.licenca) { + observacoes.push(`Licença: ${dia.licenca.tipo}`); + } + if (dia.dispensa) { + observacoes.push(`Dispensa: ${dia.dispensa.motivo}`); + } + if (dia.inconsistencias.length > 0) { + observacoes.push( + `⚠ ${dia.inconsistencias.length} inconsistência(ões): ${dia.inconsistencias.map((inc) => inc.descricao).join('; ')}` + ); + } + if (dia.ajustes.length > 0) { + observacoes.push( + `Ajustes: ${dia.ajustes.map((a) => `${a.tipo} ${Math.floor(a.valorMinutos / 60)}h ${a.valorMinutos % 60}min`).join(', ')}` + ); + } + + linha.push({ + content: observacoes.join(' | ') || '-', + styles: { fillColor: corFundo, fontSize: 8 } + }); + } else { + linha.push(''); + } + + // Coluna Status (Dentro do Prazo) + linha.push( + reg.real + ? reg.dentroDoPrazo !== undefined + ? reg.dentroDoPrazo + ? 'Sim' + : 'Não' + : 'Não marcado' + : 'Não marcado' + ); + + tableData.push(linha); + } + } + + // Cabeçalhos da tabela + const headers = ['Data', 'Tipo de Dia', 'Tipo', 'Horário']; + if (sections.saldoDiario) { + headers.push('Saldo Diário'); + } + headers.push('Observações', 'Dentro do Prazo'); + + // Salvar a posição Y antes da tabela + const yPosAntesTabela = yPosition; + + // Gerar tabela usando os dados processados + autoTable(doc, { + startY: yPosition, + head: [headers], + body: tableData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 }, + didParseCell: function (data) { + // Aplicar cores baseadas no tipo de dia e status dos registros + if (data.row.raw) { + const rowData = data.row.raw as Array< + | string + | { + content: string; + styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string }; + } + >; + // Aplicar estilos de células que têm objetos com styles + if (rowData[data.column.index] && typeof rowData[data.column.index] === 'object') { + const cellData = rowData[data.column.index] as { + content: string; + styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string }; + }; + if (cellData.styles) { + if (cellData.styles.fillColor) { + data.cell.styles.fillColor = cellData.styles.fillColor; + } + if (cellData.styles.textColor) { + data.cell.styles.textColor = cellData.styles.textColor; + } + if (cellData.styles.fontStyle) { + data.cell.styles.fontStyle = cellData.styles.fontStyle; + } + } + } + } + } + }); + + type JsPDFWithAutoTableRegistros = jsPDF & { + lastAutoTable?: { finalY: number }; + }; + const finalYRegistros = + (doc as JsPDFWithAutoTableRegistros).lastAutoTable?.finalY ?? yPosition + 10; + yPosition = finalYRegistros + 10; + + // 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; @@ -1492,9 +1727,32 @@ if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) { // Verificar se a saída correspondente também não foi marcada const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida'; - const saidaEsperada = todosRegistros.find( - (r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real - ); + + // Buscar a saída esperada que também não foi marcada + // IMPORTANTE: Garantir que estamos pegando a saída esperada correta do par, + // não confundindo com saídas esperadas de outros pares ou registros reais + const saidaEsperada = todosRegistros.find((r, idx) => { + // Deve estar após a entrada atual + if (idx <= i) return false; + // Deve ser do tipo esperado e não real + if (r.tipo !== tipoSaidaEsperado || r.real) return false; + + // Verificar se há algum registro real do mesmo tipo entre a entrada e esta saída + // Se houver, esta não é a saída esperada do par não marcado + const minutosEntrada = reg.hora * 60 + reg.minuto; + const minutosSaidaEsperada = r.hora * 60 + r.minuto; + + // Verificar se há registro real do mesmo tipo no intervalo entre entrada e saída esperada + const temRegistroRealNoIntervalo = regsReais.some((real) => { + if (real.tipo !== tipoSaidaEsperado) return false; + const minutosReal = real.hora * 60 + real.minuto; + // Se o registro real está entre a entrada e a saída esperada, não é o par correto + return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada; + }); + + // Se não há registro real no intervalo, esta é a saída esperada correta + return !temRegistroRealNoIntervalo; + }); if (saidaEsperada) { // Par completamente não marcado: calcular saldo negativo @@ -1535,10 +1793,23 @@ const diferencaHoras = Math.floor(Math.abs(diferencaMinutos) / 60); const diferencaMinutosResto = Math.abs(diferencaMinutos) % 60; - // Encontrar índice da saída esperada na lista - const indexSaidaEsperada = todosRegistros.findIndex( - (r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real - ); + // Encontrar índice da saída esperada na lista (usando a mesma lógica melhorada) + const indexSaidaEsperada = todosRegistros.findIndex((r, idx) => { + if (idx <= i) return false; + if (r.tipo !== tipoSaidaEsperado || r.real) return false; + + // Verificar se há registro real no intervalo + const minutosEntrada = reg.hora * 60 + reg.minuto; + const minutosSaidaEsperada = r.hora * 60 + r.minuto; + + const temRegistroRealNoIntervalo = regsReais.some((real) => { + if (real.tipo !== tipoSaidaEsperado) return false; + const minutosReal = real.hora * 60 + real.minuto; + return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada; + }); + + return !temRegistroRealNoIntervalo; + }); // Associar saldo negativo ao início do par (entrada) saldosEsperadosPorPar.set(i, { @@ -1678,7 +1949,7 @@ // Criar linhas da tabela for (let i = 0; i < todosRegistros.length; i++) { const reg = todosRegistros[i]; - const linha: any[] = [ + const linha: Array = [ dataFormatada, config ? getTipoRegistroLabel( @@ -1930,9 +2201,24 @@ // Há registros reais mas este par não foi marcado completamente // Verificar se é um par completamente não marcado const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida'; - const saidaEsperadaExiste = todosRegistros.some( - (r, idx) => idx > i && r.tipo === tipoSaidaEsperado && !r.real - ); + + // Verificar se a saída esperada existe usando a mesma lógica melhorada + const saidaEsperadaExiste = todosRegistros.some((r, idx) => { + if (idx <= i) return false; + if (r.tipo !== tipoSaidaEsperado || r.real) return false; + + // Verificar se há registro real no intervalo + const minutosEntrada = reg.hora * 60 + reg.minuto; + const minutosSaidaEsperada = r.hora * 60 + r.minuto; + + const temRegistroRealNoIntervalo = regsReais.some((real) => { + if (real.tipo !== tipoSaidaEsperado) return false; + const minutosReal = real.hora * 60 + real.minuto; + return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada; + }); + + return !temRegistroRealNoIntervalo; + }); if (saidaEsperadaExiste) { // Par completamente não marcado: calcular saldo negativo e decrementar saldo acumulado @@ -2266,7 +2552,7 @@ const resultadoFinalFormatado = `${sinalResultadoFinal}${horasResultadoFinal}h ${minutosResultadoFinal}min`; // Preparar dados da tabela com melhorias - const bancoHorasData: any[][] = [ + const bancoHorasData: Array<[string, string]> = [ ['Saldo Atual', saldoFormatado], ['Saldo Banco Acumulado de Períodos Anteriores', saldoAnteriorFormatado], ['Saldo do Período Exibido', saldoPeriodoDiferencaFormatado], @@ -2505,6 +2791,188 @@ } } + // ============================================ + // SEÇÃO: DETALHAMENTO DE INCONSISTÊNCIAS + // ============================================ + const todasInconsistencias = dias.flatMap((dia) => + dia.inconsistencias.map((inc) => ({ + ...inc, + data: dia.data, + dataFormatada: dia.dataFormatada + })) + ); + + if (todasInconsistencias.length > 0) { + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('DETALHAMENTO DE INCONSISTÊNCIAS', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const inconsistenciasData = todasInconsistencias.map((inc) => { + const statusLabel = + inc.status === 'resolvida' + ? 'Resolvida' + : inc.status === 'ignorada' + ? 'Ignorada' + : 'Pendente'; + const tipoLabel = + inc.tipo === 'ponto_com_atestado' + ? 'Ponto com Atestado' + : inc.tipo === 'ponto_com_licenca' + ? 'Ponto com Licença' + : inc.tipo === 'ponto_com_ausencia' + ? 'Ponto com Ausência' + : inc.tipo === 'registro_duplicado' + ? 'Registro Duplicado' + : inc.tipo === 'sequencia_invalida' + ? 'Sequência Inválida' + : inc.tipo === 'saldo_inconsistente' + ? 'Saldo Inconsistente' + : inc.tipo; + return [ + inc.dataFormatada, + tipoLabel, + inc.descricao, + statusLabel, + inc.resolvidoEm + ? `Resolvido em ${formatarDataDDMMAAAA(inc.resolvidoEm.toString())}` + : '-' + ]; + }); + + autoTable(doc, { + startY: yPosition, + head: [['Data', 'Tipo', 'Descrição', 'Status', 'Resolução']], + body: inconsistenciasData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 }, + columnStyles: { + 0: { cellWidth: 30 }, + 1: { cellWidth: 40 }, + 2: { cellWidth: 60, overflow: 'linebreak' }, + 3: { cellWidth: 30 }, + 4: { cellWidth: 30 } + }, + didParseCell: function (data) { + // Colorir status + if (data.column.index === 3) { + const status = data.cell.text[0] as string; + if (status === 'Pendente') { + data.cell.styles.textColor = [255, 140, 0]; // Laranja + data.cell.styles.fontStyle = 'bold'; + } else if (status === 'Resolvida') { + data.cell.styles.textColor = [0, 128, 0]; // Verde + } else if (status === 'Ignorada') { + data.cell.styles.textColor = [128, 128, 128]; // Cinza + } + } + } + }); + + const lastPageInconsistencias = doc.getNumberOfPages(); + doc.setPage(lastPageInconsistencias); + const finalYInconsistencias = (doc as { lastAutoTable?: { finalY: number } }) + .lastAutoTable?.finalY; + if (finalYInconsistencias) { + yPosition = finalYInconsistencias + 10; + } else { + yPosition += inconsistenciasData.length * 7 + 10; + } + } + + // ============================================ + // SEÇÃO: AJUSTES DE BANCO DE HORAS + // ============================================ + const todosAjustes = dias.flatMap((dia) => + dia.ajustes.map((ajuste) => ({ + ...ajuste, + data: dia.data, + dataFormatada: dia.dataFormatada + })) + ); + + if (todosAjustes.length > 0) { + if (yPosition > doc.internal.pageSize.getHeight() - 60) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.setTextColor(41, 128, 185); + doc.text('AJUSTES DE BANCO DE HORAS', 15, yPosition); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(0, 0, 0); + yPosition += 10; + + const ajustesData = todosAjustes.map((ajuste) => { + const tipoLabel = + ajuste.tipo === 'abonar' + ? 'Abonar' + : ajuste.tipo === 'descontar' + ? 'Descontar' + : 'Compensar'; + const horas = Math.floor(Math.abs(ajuste.valorMinutos) / 60); + const minutos = Math.abs(ajuste.valorMinutos) % 60; + const sinal = ajuste.valorMinutos >= 0 ? '+' : '-'; + const valorFormatado = `${sinal}${horas}h ${minutos}min`; + return [ + ajuste.dataFormatada, + tipoLabel, + valorFormatado, + ajuste.motivoDescricao || '-', + ajuste.gestorId ? 'Gestor' : 'Automático' + ]; + }); + + autoTable(doc, { + startY: yPosition, + head: [['Data', 'Tipo', 'Valor', 'Motivo', 'Autor']], + body: ajustesData, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' }, + styles: { fontSize: 9 }, + columnStyles: { + 0: { cellWidth: 30 }, + 1: { cellWidth: 30 }, + 2: { cellWidth: 30 }, + 3: { cellWidth: 50, overflow: 'linebreak' }, + 4: { cellWidth: 30 } + }, + didParseCell: function (data) { + // Colorir valores + if (data.column.index === 2) { + const valor = data.cell.text[0] as string; + if (valor.startsWith('+')) { + data.cell.styles.textColor = [0, 128, 0]; // Verde + data.cell.styles.fontStyle = 'bold'; + } else if (valor.startsWith('-')) { + data.cell.styles.textColor = [200, 0, 0]; // Vermelho + data.cell.styles.fontStyle = 'bold'; + } + } + } + }); + + const lastPageAjustes = doc.getNumberOfPages(); + doc.setPage(lastPageAjustes); + const finalYAjustes = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY; + if (finalYAjustes) { + yPosition = finalYAjustes + 10; + } else { + yPosition += ajustesData.length * 7 + 10; + } + } + // Dispensas de Registro if (sections.dispensasRegistro && dispensas.length > 0) { if (yPosition > doc.internal.pageSize.getHeight() - 60) { @@ -2553,18 +3021,7 @@ } // 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' } - ); - } + adicionarRodape(doc); // Salvar const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`; @@ -2577,9 +3034,22 @@ } catch (error) { console.error('Erro ao gerar PDF:', error); const errorMessage = error instanceof Error ? error.message : String(error); - toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`); + + // Mensagens de erro mais específicas + if (errorMessage.includes('Configuração de ponto não encontrada')) { + toast.error('Configuração de ponto não encontrada. Entre em contato com o administrador.'); + } else if (errorMessage.includes('Nenhum dado encontrado')) { + toast.error('Nenhum dado encontrado para este funcionário no período selecionado.'); + } else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { + toast.error('Tempo de geração excedido. Tente um período menor (máximo 90 dias).'); + } else { + toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`); + } + } finally { + carregando = false; } } + */ function abrirModalDetalhes(registroId: Id<'registrosPonto'>) { if (!registroId) { @@ -2595,7 +3065,19 @@ registroDetalhesId = ''; } - async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) { + // Wrapper para a função importada imprimirDetalhesRegistro + async function imprimirDetalhesRegistroWrapper(registroId: Id<'registrosPonto'>) { + await imprimirDetalhesRegistro( + client, + registroId, + logoGovPE, + (message: string) => toast.error(message) + ); + } + + // Função antiga comentada (foi movida para módulo) + /* + async function imprimirDetalhesRegistroOld(registroId: Id<'registrosPonto'>) { try { // Buscar dados completos do registro const registro = await client.query(api.pontos.obterRegistro, { registroId }); @@ -2642,7 +3124,7 @@ yPosition += 15; // Informações do Funcionário em tabela - const funcionarioData: any[][] = []; + const funcionarioData: Array<[string, string]> = []; if (registro.funcionario) { if (registro.funcionario.matricula) { funcionarioData.push(['Matrícula', registro.funcionario.matricula]); @@ -2703,7 +3185,7 @@ 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[][] = [ + const registroData: Array<[string, string]> = [ ['Tipo', tipoLabel], ['Data e Hora', dataHora], ['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'], @@ -2776,7 +3258,7 @@ yPosition = 20; } - const localizacaoData: any[][] = [ + const localizacaoData: Array<[string, string]> = [ ['Latitude', registro.latitude.toFixed(6)], ['Longitude', registro.longitude.toFixed(6)] ]; @@ -2857,7 +3339,7 @@ yPosition += 10; // Dados do GPS em tabela - const gpsData: any[][] = []; + const gpsData: Array<[string, string]> = []; if (registro.precisao !== null && registro.precisao !== undefined) { gpsData.push(['Precisão', `${registro.precisao.toFixed(2)} metros`]); } @@ -2894,7 +3376,7 @@ } // Confiabilidade em tabela - const confiabilidadeData: any[][] = []; + const confiabilidadeData: Array<[string, string]> = []; if (registro.confiabilidadeGPS !== null && registro.confiabilidadeGPS !== undefined) { const confiabilidadePercent = (registro.confiabilidadeGPS * 100).toFixed(1); confiabilidadeData.push(['Confiabilidade GPS (Frontend)', `${confiabilidadePercent}%`]); @@ -2965,7 +3447,7 @@ // Status de Validação em destaque if (registro.suspeitaSpoofing !== null && registro.suspeitaSpoofing !== undefined) { - const statusData: any[][] = []; + const statusData: Array<[string, string]> = []; if (registro.suspeitaSpoofing) { statusData.push(['Status', '⚠️ MARCAÇÃO SUSPEITA DETECTADA']); @@ -3066,7 +3548,7 @@ } // Análise de Propriedades GPS em tabela - const propriedadesData: any[][] = []; + const propriedadesData: Array<[string, string]> = []; let propriedadesGPS = 0; let propriedadesTotais = 5; @@ -3233,7 +3715,7 @@ } } - const geofencingData: any[][] = [ + const geofencingData: Array<[string, string]> = [ ['Endereço Esperado', enderecoEsperadoNome], ['Localização', enderecoEsperadoEndereco] ]; @@ -3383,7 +3865,7 @@ yPosition += 10; // Consolidar todos os dados técnicos em uma única tabela - const dadosTecnicosData: any[][] = []; + const dadosTecnicosData: Array<[string, string]> = []; // Informações de Rede if (registro.ipAddress || registro.ipPublico || registro.ipLocal) { @@ -3628,6 +4110,7 @@ alert('Erro ao gerar relatório detalhado. Tente novamente.'); } } + */
@@ -3815,28 +4298,30 @@
- -
-
-
+ +
+
+ +
-
- +
+ +
+
+

Registros de Ponto

+

+ Gerencie e visualize os registros de ponto dos funcionários +

-

Filtros de Busca

-
-
-
-
- - -
-
- - + +
+
+ + Filtros de Busca
+
+
+ +
+ { + const target = e.target as HTMLInputElement; + const valorComMascara = maskDate(target.value); + dataInicioExibicao = valorComMascara; -
- - -
- -
- - -
- -
- - -
-
- - - {#if statusFiltro !== 'todos' || localizacaoFiltro !== 'todos'} -
-

- {registrosFiltrados.length} registro(s) encontrado(s) com os filtros - aplicados - {#if registros.length !== registrosFiltrados.length} - - (de {registros.length} total) - - {/if} -

-
- {/if} -
-
- - -
-
-
-
-
- + // Converter para formato backend quando completo + if (validateDate(valorComMascara)) { + dataInicioInterno = formatarDataParaBackendWrapper(valorComMascara); + // Atualizar o input date oculto + const dateInput = document.getElementById( + 'data-inicio-date' + ) as HTMLInputElement; + if (dateInput) { + dateInput.value = dataInicioInterno; + } + } + }} + onblur={() => { + // Validar e corrigir ao sair do campo + if (dataInicioExibicao && !validateDate(dataInicioExibicao)) { + toast.error('Data de início inválida. Use o formato dd/mm/aaaa'); + dataInicioExibicao = formatarDataParaExibicao(dataInicioInterno); + } else if (dataInicioExibicao && validateDate(dataInicioExibicao)) { + dataInicioInterno = formatarDataParaBackendWrapper(dataInicioExibicao); + } + }} + /> + { + const target = e.target as HTMLInputElement; + if (target.value) { + dataInicioInterno = target.value; + dataInicioExibicao = formatarDataParaExibicao(target.value); + } + }} + /> + +
+
+ +
+ +
+ { + const target = e.target as HTMLInputElement; + const valorComMascara = maskDate(target.value); + dataFimExibicao = valorComMascara; + + // Converter para formato backend quando completo + if (validateDate(valorComMascara)) { + dataFimInterno = formatarDataParaBackendWrapper(valorComMascara); + // Atualizar o input date oculto + const dateInput = document.getElementById('data-fim-date') as HTMLInputElement; + if (dateInput) { + dateInput.value = dataFimInterno; + } + } + }} + onblur={() => { + // Validar e corrigir ao sair do campo + if (dataFimExibicao && !validateDate(dataFimExibicao)) { + toast.error('Data de fim inválida. Use o formato dd/mm/aaaa'); + dataFimExibicao = formatarDataParaExibicao(dataFimInterno); + } else if (dataFimExibicao && validateDate(dataFimExibicao)) { + dataFimInterno = formatarDataParaBackendWrapper(dataFimExibicao); + } + }} + /> + { + const target = e.target as HTMLInputElement; + if (target.value) { + dataFimInterno = target.value; + dataFimExibicao = formatarDataParaExibicao(target.value); + } + }} + /> + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
-

Registros de Ponto

- - {#if funcionarioIdFiltro || dataInicio || dataFim} -
+ + {#if funcionarioIdFiltro || dataInicio || dataFim || statusFiltro !== 'todos' || localizacaoFiltro !== 'todos'} +
+ Filtros ativos: {#if funcionarioIdFiltro && funcionarioSelecionadoNome} -
- +
+ {funcionarioSelecionadoNome}
{/if} {#if dataInicio} -
- +
+ De: {formatarDataDDMMAAAA(dataInicio)}
{/if} {#if dataFim} -
- +
+ Até: {formatarDataDDMMAAAA(dataFim)}
{/if} + {#if statusFiltro !== 'todos'} +
+ Status: {statusFiltro === 'dentro' ? 'Dentro do Prazo' : 'Fora do Prazo'} +
+ {/if} + {#if localizacaoFiltro !== 'todos'} +
+ Local: {localizacaoFiltro === 'dentro' ? 'Dentro do Raio' : 'Fora do Raio'} +
+ {/if} +
+ {registrosFiltrados.length} registro(s) +
{/if}
@@ -4030,92 +4624,91 @@
{:else} -
+
{#each registrosAgrupados as grupo}
-
- -
-
+
+ +
+
+
+ +
-
-
- -
-
-

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

- {#if grupo.funcionario?.matricula} -

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

- {/if} -
-
+

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

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

+

- {#if saldoPositivo} - - {:else} - - {/if} -

-
-

Banco de Horas

-

- {formatarSaldoHoras(saldoAcumulado)} -

-
+ {formatarSaldoHoras(saldoAcumulado)} +

- {/if} - {/key} +
+ {/if} + {/key} - -
+
@@ -4262,7 +4855,7 @@ mostrarModalImpressao = false; funcionarioParaImprimir = ''; }} - onGenerate={gerarPDFComSelecao} + onGenerate={gerarPDFComSelecaoWrapper} /> {/if} @@ -4286,7 +4879,9 @@ class="border-base-300 mb-4 flex flex-shrink-0 items-center justify-between border-b pb-4" >

Detalhes do Registro de Ponto

- +
@@ -4439,7 +5034,7 @@ {#if registroDetalhes} +
+ + + {#if erroErros} +
+ +
+

Erro ao carregar erros do servidor

+
{erroErros}
+
+
+ {/if} + + {#if erroEstatisticas} +
+ +
+

Erro ao carregar estatísticas

+
{erroEstatisticas}
+
+
+ {/if} + + + {#if estatisticas} +
+
+
+
+
Total de Erros
+
{estatisticas.total}
+
+
+
+
+
+
+
Notificados
+
{estatisticas.notificados}
+
+
+
+
+
+
+
Não Notificados
+
{estatisticas.naoNotificados}
+
+
+
+
+
+
+
Últimas 24h
+
{estatisticas.ultimas24h}
+
+
+
+
+ {/if} + + + {#if mostrarFiltros} +
+
+
+

Filtros

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ {/if} + + +
+
+ {#if carregando} +
+ +
+ {:else if erros.length === 0} +
+ +

Nenhum erro encontrado

+

+ Não há erros registrados com os filtros aplicados. +

+
+

💡 Como funciona:

+
    +
  • Erros 404 e 500 são registrados automaticamente pelo sistema
  • +
  • Os erros aparecem aqui após ocorrerem no sistema
  • +
  • Você pode testar acessando uma URL inexistente para gerar um erro 404
  • +
+
+
+ {:else} +
+ + + + + + + + + + + + + + {#each erros as erro (erro._id)} + {@const StatusIcon = obterIconeStatusCode(erro.statusCode)} + + + + + + + + + + {/each} + +
Data/HoraCódigoURLMétodoUsuárioNotificadoAções
+
+ + {formatarData(erro.criadoEm)} +
+
+ + + {erro.statusCode} + + +
+ {erro.url || 'N/A'} +
+
+ {erro.method || 'N/A'} + {erro.usuarioNome || 'Anônimo'} + {#if erro.notificado} + + + Sim + + {:else} + + + Não + + {/if} + + +
+
+ {/if} +
+
+
+ + + {#if erroSelecionado} + + {/if} + + + diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte index af4381e..816ca03 100644 --- a/apps/web/src/routes/+error.svelte +++ b/apps/web/src/routes/+error.svelte @@ -69,3 +69,7 @@
+ + + + diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index deaea6d..dba9552 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -35,6 +35,7 @@ import type * as documentos from "../documentos.js"; import type * as email from "../email.js"; import type * as empresas from "../empresas.js"; import type * as enderecosMarcacao from "../enderecosMarcacao.js"; +import type * as errosServidor from "../errosServidor.js"; import type * as ferias from "../ferias.js"; import type * as flows from "../flows.js"; import type * as funcionarioEnderecos from "../funcionarioEnderecos.js"; @@ -123,6 +124,7 @@ declare const fullApi: ApiFromModules<{ email: typeof email; empresas: typeof empresas; enderecosMarcacao: typeof enderecosMarcacao; + errosServidor: typeof errosServidor; ferias: typeof ferias; flows: typeof flows; funcionarioEnderecos: typeof funcionarioEnderecos; diff --git a/packages/backend/convex/atestadosLicencas.ts b/packages/backend/convex/atestadosLicencas.ts index cd90e01..704fdc5 100644 --- a/packages/backend/convex/atestadosLicencas.ts +++ b/packages/backend/convex/atestadosLicencas.ts @@ -59,6 +59,85 @@ async function recalcularBancoHorasPeriodo( } } +/** + * Helper para verificar se um funcionário tem licença ou atestado ativo + * Retorna true se há algum registro ativo (data atual entre dataInicio e dataFim) + */ +export async function verificarLicencaAtiva( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + dataAtual?: Date +): Promise { + // Normalizar data atual para comparar apenas a parte da data (sem hora) + const hoje = dataAtual || new Date(); + const hojeStr = hoje.toISOString().split('T')[0]; // Formato: "YYYY-MM-DD" + + console.log( + `[verificarLicencaAtiva] Verificando funcionário ${funcionarioId}, data atual: ${hojeStr}` + ); + + // Buscar atestados e licenças do funcionário + const [atestados, licencas] = await Promise.all([ + ctx.db + .query('atestados') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .collect(), + ctx.db + .query('licencas') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .collect() + ]); + + console.log( + `[verificarLicencaAtiva] Encontrados ${atestados.length} atestados e ${licencas.length} licenças` + ); + + // Verificar se há algum atestado ativo + for (const atestado of atestados) { + // Normalizar datas para formato "YYYY-MM-DD" (pode vir como "YYYY-MM-DD" ou "YYYY-MM-DDTHH:mm:ss") + const inicioStr = atestado.dataInicio.includes('T') + ? atestado.dataInicio.split('T')[0] + : atestado.dataInicio.substring(0, 10); + const fimStr = atestado.dataFim.includes('T') + ? atestado.dataFim.split('T')[0] + : atestado.dataFim.substring(0, 10); + + console.log( + `[verificarLicencaAtiva] Atestado: ${inicioStr} a ${fimStr}, hoje: ${hojeStr}, ativo: ${hojeStr >= inicioStr && hojeStr <= fimStr}` + ); + + // Comparar strings de data diretamente (formato ISO permite comparação lexicográfica) + if (hojeStr >= inicioStr && hojeStr <= fimStr) { + console.log(`[verificarLicencaAtiva] ✅ Atestado ativo encontrado!`); + return true; + } + } + + // Verificar se há alguma licença ativa + for (const licenca of licencas) { + // Normalizar datas para formato "YYYY-MM-DD" (pode vir como "YYYY-MM-DD" ou "YYYY-MM-DDTHH:mm:ss") + const inicioStr = licenca.dataInicio.includes('T') + ? licenca.dataInicio.split('T')[0] + : licenca.dataInicio.substring(0, 10); + const fimStr = licenca.dataFim.includes('T') + ? licenca.dataFim.split('T')[0] + : licenca.dataFim.substring(0, 10); + + console.log( + `[verificarLicencaAtiva] Licença: ${inicioStr} a ${fimStr}, hoje: ${hojeStr}, ativa: ${hojeStr >= inicioStr && hojeStr <= fimStr}` + ); + + // Comparar strings de data diretamente (formato ISO permite comparação lexicográfica) + if (hojeStr >= inicioStr && hojeStr <= fimStr) { + console.log(`[verificarLicencaAtiva] ✅ Licença ativa encontrada!`); + return true; + } + } + + console.log(`[verificarLicencaAtiva] ❌ Nenhuma licença/atestado ativo encontrado`); + return false; +} + // ========== QUERIES ========== /** @@ -238,6 +317,19 @@ export const listarPorPeriodo = query({ } }); +/** + * Verificar se o funcionário atual tem licença/atestado ativo + */ +export const verificarStatusLicenca = query({ + args: { + funcionarioId: v.id('funcionarios') + }, + returns: v.boolean(), + handler: async (ctx, args) => { + return await verificarLicencaAtiva(ctx, args.funcionarioId); + } +}); + /** * Obter dados para gráficos */ @@ -816,6 +908,15 @@ export const criarAtestadoMedico = mutation({ // Recalcular banco de horas para todas as datas do período do atestado await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + // Atualizar status do funcionário imediatamente + console.log( + `[criarAtestadoMedico] Atualizando status do funcionário ${args.funcionarioId} após criar atestado` + ); + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: args.funcionarioId + }); + console.log(`[criarAtestadoMedico] Status atualizado com sucesso`); + return atestadoId; } }); @@ -864,6 +965,11 @@ export const criarDeclaracaoComparecimento = mutation({ // Recalcular banco de horas para todas as datas do período da declaração await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + // Atualizar status do funcionário imediatamente + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: args.funcionarioId + }); + return atestadoId; } }); @@ -920,6 +1026,11 @@ export const criarLicencaMaternidade = mutation({ // Recalcular banco de horas para todas as datas do período da licença await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + // Atualizar status do funcionário imediatamente + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: args.funcionarioId + }); + return licencaId; } }); @@ -969,6 +1080,11 @@ export const criarLicencaPaternidade = mutation({ // Recalcular banco de horas para todas as datas do período da licença await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + // Atualizar status do funcionário imediatamente + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: args.funcionarioId + }); + return licencaId; } }); @@ -1025,6 +1141,11 @@ export const prorrogarLicencaMaternidade = mutation({ prorrogacaoId ); + // Atualizar status do funcionário imediatamente + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: licencaOriginal.funcionarioId + }); + return prorrogacaoId; } }); @@ -1055,6 +1176,11 @@ export const excluirAtestado = mutation({ args.id ); + // Atualizar status do funcionário imediatamente + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: atestado.funcionarioId + }); + return null; } }); @@ -1085,6 +1211,11 @@ export const excluirLicenca = mutation({ args.id ); + // Atualizar status do funcionário imediatamente + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: licenca.funcionarioId + }); + return null; } }); diff --git a/packages/backend/convex/errosServidor.ts b/packages/backend/convex/errosServidor.ts new file mode 100644 index 0000000..a98b7d0 --- /dev/null +++ b/packages/backend/convex/errosServidor.ts @@ -0,0 +1,424 @@ +import { action, internalMutation, internalAction, internalQuery, query } from './_generated/server'; +import { v } from 'convex/values'; +import { api, internal } from './_generated/api'; +import type { Id } from './_generated/dataModel'; +import { getCurrentUserFunction } from './auth'; + +/** + * Action pública para registrar erro do servidor e notificar equipe técnica + * Esta função será chamada pelo handleError do SvelteKit + */ +export const registrarErroServidor = action({ + args: { + statusCode: v.number(), + mensagem: v.string(), + stack: v.optional(v.string()), + url: v.optional(v.string()), + method: v.optional(v.string()), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + usuarioId: v.optional(v.id('usuarios')) + }, + handler: async (ctx, args) => { + // Registrar erro no banco + const erroId = await ctx.runMutation(internal.errosServidor.inserirErro, { + statusCode: args.statusCode, + mensagem: args.mensagem, + stack: args.stack, + url: args.url, + method: args.method, + ipAddress: args.ipAddress, + userAgent: args.userAgent, + usuarioId: args.usuarioId + }); + + // Notificar equipe técnica (assíncrono) + ctx.scheduler + .runAfter(0, internal.errosServidor.notificarEquipeTecnica, { + erroId + }) + .catch((error) => { + console.error('Erro ao agendar notificação de erro:', error); + }); + + return { sucesso: true, erroId }; + } +}); + +/** + * Mutation interna para inserir erro no banco + */ +export const inserirErro = internalMutation({ + args: { + statusCode: v.number(), + mensagem: v.string(), + stack: v.optional(v.string()), + url: v.optional(v.string()), + method: v.optional(v.string()), + ipAddress: v.optional(v.string()), + userAgent: v.optional(v.string()), + usuarioId: v.optional(v.id('usuarios')) + }, + handler: async (ctx, args) => { + const erroId = await ctx.db.insert('errosServidor', { + statusCode: args.statusCode, + mensagem: args.mensagem, + stack: args.stack, + url: args.url, + method: args.method, + ipAddress: args.ipAddress, + userAgent: args.userAgent, + usuarioId: args.usuarioId, + notificado: false, + criadoEm: Date.now() + }); + + return erroId; + } +}); + +/** + * Action interna para notificar equipe técnica sobre erro do servidor + */ +export const notificarEquipeTecnica = internalAction({ + args: { + erroId: v.id('errosServidor') + }, + handler: async (ctx, args) => { + // Buscar detalhes do erro + const erro = await ctx.runQuery(internal.errosServidor.obterErroPorId, { + erroId: args.erroId + }); + + if (!erro) { + console.error('Erro não encontrado:', args.erroId); + return; + } + + // Buscar usuários da equipe técnica (roles com nível <= 1) + const rolesAdminOuTi = await ctx.runQuery(internal.errosServidor.obterRolesTI, {}); + + if (rolesAdminOuTi.length === 0) { + console.warn('Nenhuma role de TI encontrada para notificação de erro'); + return; + } + + const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id)); + const usuarios = await ctx.runQuery(internal.errosServidor.obterUsuariosTI, { + rolesPermitidas: Array.from(rolesPermitidas) + }); + + if (usuarios.length === 0) { + console.warn('Nenhum usuário de TI encontrado para notificação de erro'); + return; + } + + // Preparar informações do erro para notificação + const urlFormatada = erro.url || 'N/A'; + const metodoFormatado = erro.method || 'N/A'; + const stackFormatado = erro.stack + ? erro.stack.substring(0, 500) + (erro.stack.length > 500 ? '...' : '') + : 'N/A'; + + // Notificar via chat (notificações internas) + for (const usuario of usuarios) { + await ctx.runMutation(internal.errosServidor.criarNotificacaoChat, { + usuarioId: usuario._id, + statusCode: erro.statusCode, + mensagem: erro.mensagem, + url: urlFormatada, + method: metodoFormatado + }); + } + + // Notificar via email (apenas para usuários com email) + const usuariosComEmail = usuarios.filter((u) => u.email); + + for (const usuario of usuariosComEmail) { + try { + // Determinar código do template baseado no status code + const templateCodigo = + erro.statusCode === 404 ? 'ERRO_SERVIDOR_404' : 'ERRO_SERVIDOR_500'; + + // Verificar se existe template de erro do servidor + const templateExiste = await ctx.runQuery( + api.templatesMensagens.obterTemplatePorCodigo, + { + codigo: templateCodigo + } + ); + + if (templateExiste) { + // Usar template personalizado + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { + destinatario: usuario.email!, + destinatarioId: usuario._id, + templateCodigo, + variaveis: { + destinatarioNome: usuario.nome, + statusCode: erro.statusCode.toString(), + mensagem: erro.mensagem, + url: urlFormatada, + method: metodoFormatado, + stack: stackFormatado, + timestamp: new Date(erro.criadoEm).toLocaleString('pt-BR') + }, + enviadoPor: usuario._id // Usar o próprio usuário como remetente + }); + } else { + // Template não existe, criar email simples com HTML básico + const assunto = + erro.statusCode === 404 + ? `⚠️ Erro 404 - Página não encontrada: ${urlFormatada.substring(0, 50)}` + : `🚨 Erro ${erro.statusCode} no Servidor - ${urlFormatada.substring(0, 50)}`; + const corpo = ` +

${erro.statusCode === 404 ? 'Página Não Encontrada (404)' : 'Erro do Servidor Detectado'}

+

Código: ${erro.statusCode}

+

Mensagem: ${erro.mensagem}

+

URL: ${urlFormatada}

+

Método: ${metodoFormatado}

+

Data/Hora: ${new Date(erro.criadoEm).toLocaleString('pt-BR')}

+ ${erro.stack && erro.statusCode !== 404 ? `

Stack Trace:

${stackFormatado}

` : ''} + `; + + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: usuario.email!, + destinatarioId: usuario._id, + assunto, + corpo, + enviadoPor: usuario._id + }); + } + } catch (error) { + console.error(`Erro ao enviar email de notificação para ${usuario.email}:`, error); + } + } + + // Marcar erro como notificado + await ctx.runMutation(internal.errosServidor.marcarComoNotificado, { + erroId: args.erroId + }); + } +}); + +/** + * Query interna para obter erro por ID + */ +export const obterErroPorId = internalQuery({ + args: { + erroId: v.id('errosServidor') + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.erroId); + } +}); + +/** + * Query interna para obter roles de TI + */ +export const obterRolesTI = internalQuery({ + args: {}, + handler: async (ctx) => { + return await ctx.db + .query('roles') + .filter((q) => q.lte(q.field('nivel'), 1)) + .collect(); + } +}); + +/** + * Query interna para obter usuários de TI + */ +export const obterUsuariosTI = internalQuery({ + args: { + rolesPermitidas: v.array(v.id('roles')) + }, + handler: async (ctx, args) => { + const usuarios = await ctx.db.query('usuarios').collect(); + return usuarios.filter((u) => args.rolesPermitidas.includes(u.roleId)); + } +}); + +/** + * Mutation interna para criar notificação no chat + */ +export const criarNotificacaoChat = internalMutation({ + args: { + usuarioId: v.id('usuarios'), + statusCode: v.number(), + mensagem: v.string(), + url: v.string(), + method: v.string() + }, + handler: async (ctx, args) => { + const tituloNotificacao = + args.statusCode === 404 + ? `⚠️ Erro 404 - Página não encontrada` + : `🚨 Erro ${args.statusCode} no Servidor`; + const descricaoNotificacao = + args.statusCode === 404 + ? `Página não encontrada: ${args.url} (${args.method})` + : `Erro detectado em ${args.url} (${args.method}): ${args.mensagem.substring(0, 100)}`; + + await ctx.db.insert('notificacoes', { + usuarioId: args.usuarioId, + tipo: 'nova_mensagem', + titulo: tituloNotificacao, + descricao: descricaoNotificacao, + lida: false, + criadaEm: Date.now() + }); + } +}); + +/** + * Mutation interna para marcar erro como notificado + */ +export const marcarComoNotificado = internalMutation({ + args: { + erroId: v.id('errosServidor') + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.erroId, { + notificado: true, + notificadoEm: Date.now() + }); + } +}); + +/** + * Query pública para listar erros do servidor (apenas para TI) + */ +export const listarErros = query({ + args: { + limite: v.optional(v.number()), + statusCode: v.optional(v.number()), + notificado: v.optional(v.boolean()), + dataInicio: v.optional(v.number()), + dataFim: v.optional(v.number()) + }, + handler: async (ctx, args) => { + // Verificar se usuário tem permissão (nível <= 1) + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Não autenticado'); + } + + const role = await ctx.db.get(usuario.roleId); + if (!role || role.nivel > 1) { + throw new Error('Acesso negado. Apenas usuários de TI podem visualizar erros do servidor.'); + } + + // Construir query com filtros + let erros; + if (args.statusCode !== undefined) { + erros = await ctx.db + .query('errosServidor') + .withIndex('by_status_code', (q) => q.eq('statusCode', args.statusCode!)) + .collect(); + } else { + erros = await ctx.db + .query('errosServidor') + .withIndex('by_criado_em') + .collect(); + } + + // Aplicar filtros adicionais que não são índices + if (args.notificado !== undefined) { + erros = erros.filter((e) => e.notificado === args.notificado); + } + + if (args.dataInicio !== undefined) { + erros = erros.filter((e) => e.criadoEm >= args.dataInicio!); + } + + if (args.dataFim !== undefined) { + erros = erros.filter((e) => e.criadoEm <= args.dataFim!); + } + + // Ordenar por data (mais recentes primeiro) + erros.sort((a, b) => b.criadoEm - a.criadoEm); + + // Aplicar limite + const limite = args.limite || 100; + erros = erros.slice(0, limite); + + // Buscar informações do usuário se houver usuarioId + const errosComUsuario = await Promise.all( + erros.map(async (erro) => { + let usuarioNome = null; + if (erro.usuarioId) { + const usuarioErro = await ctx.db.get(erro.usuarioId); + usuarioNome = usuarioErro?.nome || null; + } + return { + ...erro, + usuarioNome + }; + }) + ); + + return errosComUsuario; + } +}); + +/** + * Query pública para obter estatísticas de erros + */ +export const obterEstatisticasErros = query({ + args: { + dataInicio: v.optional(v.number()), + dataFim: v.optional(v.number()) + }, + handler: async (ctx, args) => { + // Verificar se usuário tem permissão (nível <= 1) + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Não autenticado'); + } + + const role = await ctx.db.get(usuario.roleId); + if (!role || role.nivel > 1) { + throw new Error('Acesso negado. Apenas usuários de TI podem visualizar estatísticas de erros.'); + } + + // Buscar todos os erros no período + let erros = await ctx.db.query('errosServidor').withIndex('by_criado_em').collect(); + + // Aplicar filtros de data + if (args.dataInicio !== undefined) { + erros = erros.filter((e) => e.criadoEm >= args.dataInicio!); + } + + if (args.dataFim !== undefined) { + erros = erros.filter((e) => e.criadoEm <= args.dataFim!); + } + + // Calcular estatísticas + const total = erros.length; + const porStatus = erros.reduce( + (acc, erro) => { + const status = erro.statusCode.toString(); + acc[status] = (acc[status] || 0) + 1; + return acc; + }, + {} as Record + ); + + const notificados = erros.filter((e) => e.notificado).length; + const naoNotificados = total - notificados; + + // Erros mais recentes (últimas 24 horas) + const agora = Date.now(); + const ultimas24h = erros.filter((e) => agora - e.criadoEm <= 24 * 60 * 60 * 1000).length; + + return { + total, + porStatus, + notificados, + naoNotificados, + ultimas24h + }; + } +}); + diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts index 77d26ff..9308d1a 100644 --- a/packages/backend/convex/ferias.ts +++ b/packages/backend/convex/ferias.ts @@ -2,6 +2,8 @@ import { v } from 'convex/values'; import { mutation, query, internalMutation } from './_generated/server'; import { internal } from './_generated/api'; import { Id, Doc } from './_generated/dataModel'; +import { verificarLicencaAtiva } from './atestadosLicencas'; +import { getCurrentUserFunction } from './auth'; // Validador para períodos const periodoValidator = v.object({ @@ -46,7 +48,7 @@ function agruparPorSolicitacao(registros: Array>): Array<{ grupos.get(chave)!.push(registro); } - return Array.from(grupos.entries()).map(([_, periodos]) => { + return Array.from(grupos.entries()).map(([, periodos]) => { // Ordenar por data de criação para manter ordem periodos.sort((a, b) => a._creationTime - b._creationTime); @@ -819,7 +821,15 @@ export const atualizarStatusTodosFuncionarios = internalMutation({ } } - const novoStatus = emFerias ? 'em_ferias' : 'ativo'; + // Determinar o status: férias tem prioridade sobre licença + let novoStatus: 'ativo' | 'em_ferias' | 'em_licenca'; + if (emFerias) { + novoStatus = 'em_ferias'; + } else { + // Se não está em férias, verificar se está em licença + const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje); + novoStatus = emLicenca ? 'em_licenca' : 'ativo'; + } if (func.statusFerias !== novoStatus) { await ctx.db.patch(func._id, { statusFerias: novoStatus }); @@ -829,3 +839,142 @@ export const atualizarStatusTodosFuncionarios = internalMutation({ return null; } }); + +// Internal Mutation: Atualizar status de um funcionário específico +export const atualizarStatusFuncionario = internalMutation({ + args: { + funcionarioId: v.id('funcionarios') + }, + returns: v.null(), + handler: async (ctx, args) => { + const func = await ctx.db.get(args.funcionarioId); + if (!func) return null; + + const hoje = new Date(); + hoje.setHours(0, 0, 0, 0); + + // Buscar todos os registros de férias que podem estar em férias + const feriasAprovadas = await ctx.db + .query('ferias') + .withIndex('by_funcionario_and_status', (q) => + q.eq('funcionarioId', func._id).eq('status', 'aprovado') + ) + .collect(); + + const feriasAjustadas = await ctx.db + .query('ferias') + .withIndex('by_funcionario_and_status', (q) => + q.eq('funcionarioId', func._id).eq('status', 'data_ajustada_aprovada') + ) + .collect(); + + const feriasEmFerias = await ctx.db + .query('ferias') + .withIndex('by_funcionario_and_status', (q) => + q.eq('funcionarioId', func._id).eq('status', 'EmFérias') + ) + .collect(); + + const idsAprovados = new Set(feriasAprovadas.map((f) => f._id)); + const idsAjustados = new Set(feriasAjustadas.map((f) => f._id)); + const statusAnteriorPorId = new Map, 'aprovado' | 'data_ajustada_aprovada'>(); + + for (const ferias of feriasEmFerias) { + if (ferias.historicoAlteracoes && ferias.historicoAlteracoes.length > 0) { + const historico = ferias.historicoAlteracoes; + for (let i = historico.length - 1; i >= 0; i--) { + const entrada = historico[i]; + if (entrada.acao.includes('Aprovado') || entrada.acao.includes('aprovado')) { + statusAnteriorPorId.set(ferias._id, 'aprovado'); + break; + } else if (entrada.acao.includes('Data ajustada') || entrada.acao.includes('ajustada')) { + statusAnteriorPorId.set(ferias._id, 'data_ajustada_aprovada'); + break; + } + } + } + if (!statusAnteriorPorId.has(ferias._id)) { + statusAnteriorPorId.set(ferias._id, 'aprovado'); + } + } + + const todasFerias = [...feriasAprovadas, ...feriasAjustadas, ...feriasEmFerias]; + + let emFerias = false; + for (const ferias of todasFerias) { + const inicio = new Date(ferias.dataInicio); + const fim = new Date(ferias.dataFim); + inicio.setHours(0, 0, 0, 0); + fim.setHours(23, 59, 59, 999); + + if (hoje >= inicio && hoje <= fim) { + emFerias = true; + + if (ferias.status !== 'EmFérias') { + await ctx.db.patch(ferias._id, { + status: 'EmFérias' + }); + } + } else { + if (ferias.status === 'EmFérias') { + let statusAnterior: 'aprovado' | 'data_ajustada_aprovada'; + + if (idsAprovados.has(ferias._id)) { + statusAnterior = 'aprovado'; + } else if (idsAjustados.has(ferias._id)) { + statusAnterior = 'data_ajustada_aprovada'; + } else { + statusAnterior = statusAnteriorPorId.get(ferias._id) || 'aprovado'; + } + + await ctx.db.patch(ferias._id, { + status: statusAnterior + }); + } + } + } + + // Determinar o status: férias tem prioridade sobre licença + let novoStatus: 'ativo' | 'em_ferias' | 'em_licenca'; + if (emFerias) { + novoStatus = 'em_ferias'; + console.log(`[atualizarStatusFuncionario] Funcionário ${func._id} está em férias`); + } else { + // Se não está em férias, verificar se está em licença + const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje); + novoStatus = emLicenca ? 'em_licenca' : 'ativo'; + console.log( + `[atualizarStatusFuncionario] Funcionário ${func._id}: emLicenca=${emLicenca}, novoStatus=${novoStatus}` + ); + } + + if (func.statusFerias !== novoStatus) { + console.log( + `[atualizarStatusFuncionario] Atualizando status de ${func.statusFerias} para ${novoStatus}` + ); + await ctx.db.patch(func._id, { statusFerias: novoStatus }); + } else { + console.log(`[atualizarStatusFuncionario] Status já está correto: ${novoStatus}`); + } + + return null; + } +}); + +// Mutation pública para atualizar status do funcionário atual (útil para debug/teste) +export const atualizarMeuStatus = mutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario || !usuario.funcionarioId) { + throw new Error('Usuário não encontrado ou não possui funcionário associado'); + } + + await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, { + funcionarioId: usuario.funcionarioId + }); + + return null; + } +}); diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 5410d4f..59a7217 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2919,14 +2919,30 @@ export const obterEstatisticasBancoHorasGerencial = query({ const funcionariosComDetalhes = await Promise.all( bancosMensais.map(async (banco) => { const funcionario = await ctx.db.get(banco.funcionarioId); + if (!funcionario) { + return { + ...banco, + funcionario: null + }; + } + + // Buscar foto do perfil do funcionário através do usuário associado + let fotoPerfilUrl: string | null = null; + 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); + } + return { ...banco, - funcionario: funcionario - ? { - nome: funcionario.nome, - matricula: funcionario.matricula - } - : null + funcionario: { + nome: funcionario.nome, + matricula: funcionario.matricula, + fotoPerfilUrl + } }; }) ); diff --git a/packages/backend/convex/tables/funcionarios.ts b/packages/backend/convex/tables/funcionarios.ts index 826ea19..7eb563a 100644 --- a/packages/backend/convex/tables/funcionarios.ts +++ b/packages/backend/convex/tables/funcionarios.ts @@ -35,7 +35,9 @@ export const funcionariosTables = { simboloId: v.id('simbolos'), simboloTipo: simboloTipo, gestorId: v.optional(v.id('usuarios')), - statusFerias: v.optional(v.union(v.literal('ativo'), v.literal('em_ferias'))), + statusFerias: v.optional( + v.union(v.literal('ativo'), v.literal('em_ferias'), v.literal('em_licenca')) + ), // Regime de trabalho (para cálculo correto de férias) regimeTrabalho: v.optional( diff --git a/packages/backend/convex/tables/system.ts b/packages/backend/convex/tables/system.ts index 6cb4294..b53cde2 100644 --- a/packages/backend/convex/tables/system.ts +++ b/packages/backend/convex/tables/system.ts @@ -217,5 +217,24 @@ export const systemTables = { 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']) + }).index('by_ativo', ['ativo']), + + // Logs de Erros do Servidor (500, etc) + errosServidor: defineTable({ + statusCode: v.number(), // Código HTTP do erro (500, 502, etc) + mensagem: v.string(), // Mensagem do erro + stack: v.optional(v.string()), // Stack trace do erro + url: v.optional(v.string()), // URL onde ocorreu o erro + method: v.optional(v.string()), // Método HTTP (GET, POST, etc) + ipAddress: v.optional(v.string()), // IP do cliente + userAgent: v.optional(v.string()), // User agent do navegador + usuarioId: v.optional(v.id('usuarios')), // Usuário autenticado (se houver) + notificado: v.boolean(), // Se a equipe técnica já foi notificada + notificadoEm: v.optional(v.number()), // Timestamp da notificação + criadoEm: v.number() // Timestamp do erro + }) + .index('by_status_code', ['statusCode']) + .index('by_notificado', ['notificado']) + .index('by_criado_em', ['criadoEm']) + .index('by_usuario', ['usuarioId']) }; diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index b1909de..48ed433 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -859,6 +859,85 @@ export const criarTemplatesPadrao = mutation({ ], categoria: 'email' as const, tags: ['seguranca', 'anomalia', 'alerta', 'cybersecurity'] + }, + // ===================== NOTIFICAÇÕES DE ERROS DO SERVIDOR ===================== + { + codigo: 'ERRO_SERVIDOR_404', + nome: 'Erro 404 - Página Não Encontrada', + titulo: '⚠️ Erro 404 - Página não encontrada', + corpo: + "" + + "
" + + "

⚠️ Erro 404 - Página Não Encontrada

" + + '

Olá {{destinatarioNome}},

' + + '

O sistema detectou uma tentativa de acesso a uma página que não existe:

' + + "
" + + "

URL: {{url}}

" + + "

Método HTTP: {{method}}

" + + "

Mensagem: {{mensagem}}

" + + "

Data/Hora: {{timestamp}}

" + + '
' + + "

" + + 'Possíveis causas:
' + + '• Link quebrado ou desatualizado
' + + '• URL digitada incorretamente
' + + '• Página movida ou removida
' + + '• Tentativa de acesso a recurso inexistente' + + '

' + + "

" + + 'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de TI' + + '

' + + '
', + variaveis: [ + 'destinatarioNome', + 'url', + 'method', + 'mensagem', + 'timestamp' + ], + categoria: 'email' as const, + tags: ['erro', '404', 'servidor', 'notificacao', 'ti'] + }, + { + codigo: 'ERRO_SERVIDOR_500', + nome: 'Erro 500 - Erro Interno do Servidor', + titulo: '🚨 Erro 500 - Erro Interno do Servidor', + corpo: + "" + + "
" + + "

🚨 Erro 500 - Erro Interno do Servidor

" + + '

Olá {{destinatarioNome}},

' + + '

O sistema detectou um erro interno do servidor que requer atenção imediata:

' + + "
" + + "

Código HTTP: {{statusCode}}

" + + "

URL: {{url}}

" + + "

Método HTTP: {{method}}

" + + "

Mensagem: {{mensagem}}

" + + "

Data/Hora: {{timestamp}}

" + + '
' + + "
" + + "

Stack Trace:

" + + "
{{stack}}
" + + '
' + + "

" + + '⚠️ AÇÃO IMEDIATA NECESSÁRIA' + + '

' + + "

" + + 'SGSE - Sistema de Gerenciamento de Secretaria - Equipe de TI
' + + 'Este é um alerta automático do sistema de monitoramento de erros.' + + '

' + + '
', + variaveis: [ + 'destinatarioNome', + 'statusCode', + 'url', + 'method', + 'mensagem', + 'stack', + 'timestamp' + ], + categoria: 'email' as const, + tags: ['erro', '500', 'servidor', 'critico', 'notificacao', 'ti'] } ];