From f0c6e4468f865852c79058abf6dbf0f92449bf9a Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 18 Nov 2025 11:44:12 -0300 Subject: [PATCH 1/8] feat: integrate point management features into the dashboard - Added a new "Meu Ponto" section for users to register their work hours, breaks, and attendance. - Introduced a "Controle de Ponto" category in the Recursos Humanos section for managing employee time records. - Enhanced the backend schema to support point registration and configuration settings. - Updated various components to improve UI consistency and user experience across the dashboard. --- .../components/ponto/ComprovantePonto.svelte | 268 +++++++++++ .../lib/components/ponto/RegistroPonto.svelte | 286 ++++++++++++ .../ponto/RelogioSincronizado.svelte | 118 +++++ .../lib/components/ponto/WebcamCapture.svelte | 199 ++++++++ .../ponto/WidgetGestaoPontos.svelte | 68 +++ apps/web/src/lib/utils/deviceInfo.ts | 397 ++++++++++++++++ apps/web/src/lib/utils/ponto.ts | 103 ++++ apps/web/src/lib/utils/sincronizacaoTempo.ts | 56 +++ apps/web/src/lib/utils/webcam.ts | 150 ++++++ .../(dashboard)/gestao-pessoas/+page.svelte | 6 + .../routes/(dashboard)/perfil/+page.svelte | 375 ++++++++++----- .../(dashboard)/recursos-humanos/+page.svelte | 17 + .../registro-pontos/+page.svelte | 355 ++++++++++++++ .../secretaria-executiva/+page.svelte | 6 + .../src/routes/(dashboard)/ti/+page.svelte | 18 + .../ti/configuracoes-ponto/+page.svelte | 191 ++++++++ .../ti/configuracoes-relogio/+page.svelte | 250 ++++++++++ packages/backend/convex/_generated/api.d.ts | 6 + packages/backend/convex/configuracaoPonto.ts | 126 +++++ .../backend/convex/configuracaoRelogio.ts | 202 ++++++++ packages/backend/convex/pontos.ts | 442 ++++++++++++++++++ packages/backend/convex/schema.ts | 93 +++- 22 files changed, 3604 insertions(+), 128 deletions(-) create mode 100644 apps/web/src/lib/components/ponto/ComprovantePonto.svelte create mode 100644 apps/web/src/lib/components/ponto/RegistroPonto.svelte create mode 100644 apps/web/src/lib/components/ponto/RelogioSincronizado.svelte create mode 100644 apps/web/src/lib/components/ponto/WebcamCapture.svelte create mode 100644 apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte create mode 100644 apps/web/src/lib/utils/deviceInfo.ts create mode 100644 apps/web/src/lib/utils/ponto.ts create mode 100644 apps/web/src/lib/utils/sincronizacaoTempo.ts create mode 100644 apps/web/src/lib/utils/webcam.ts create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes-ponto/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte create mode 100644 packages/backend/convex/configuracaoPonto.ts create mode 100644 packages/backend/convex/configuracaoRelogio.ts create mode 100644 packages/backend/convex/pontos.ts diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte new file mode 100644 index 0000000..3e149e9 --- /dev/null +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -0,0 +1,268 @@ + + + + diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte new file mode 100644 index 0000000..10402cf --- /dev/null +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -0,0 +1,286 @@ + + +
+ +
+
+ +
+
+ + +
+
+

+ + Horários do Dia +

+
+ {#each mapaHorarios as horario} +
+
+
+ {horario.label} + {#if horario.registrado} + {#if horario.dentroDoPrazo} + + {:else} + + {/if} + {/if} +
+
{horario.horario}
+ {#if horario.registrado} +
+ Registrado: {horario.horarioRegistrado} +
+ {/if} +
+
+ {/each} +
+
+
+ + +
+
+

Registrar Ponto

+
+ {#if erro} +
+ + {erro} +
+ {/if} + + {#if sucesso} +
+ + {sucesso} +
+ {/if} + +
+

Próximo registro: {tipoLabel}

+
+ +
+ {#if !imagemCapturada} + + {:else} +
+ + Foto capturada +
+ {/if} + + +
+
+
+
+ + + {#if mostrandoWebcam} + + {/if} + + + {#if mostrandoComprovante && registroId} + + {/if} +
+ diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte new file mode 100644 index 0000000..aaf2e5b --- /dev/null +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -0,0 +1,118 @@ + + +
+
{horaFormatada}
+
{dataFormatada}
+
+ {#if sincronizado} + + + {#if usandoServidorExterno} + Sincronizado com servidor NTP + {:else} + Sincronizado com servidor + {/if} + + {:else if erro} + + {erro} + {:else} + + Sincronizando... + {/if} +
+
+ diff --git a/apps/web/src/lib/components/ponto/WebcamCapture.svelte b/apps/web/src/lib/components/ponto/WebcamCapture.svelte new file mode 100644 index 0000000..ee3ac88 --- /dev/null +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -0,0 +1,199 @@ + + +
+ {#if !webcamDisponivel && !erro} +
+ + Verificando webcam... +
+ {:else if erro && !webcamDisponivel} +
+ + {erro} +
+ + {:else if previewUrl} + +
+ Preview +
+ + + +
+
+ {:else} + +
+
+ + +
+ {#if erro} +
+ {erro} +
+ {/if} +
+ + +
+
+ {/if} +
+ diff --git a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte new file mode 100644 index 0000000..183d11a --- /dev/null +++ b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte @@ -0,0 +1,68 @@ + + +
+
+
+
+
+ +
+
+

Gestão de Pontos

+

Registros de ponto do dia

+
+
+
+ + {#if estatisticas} +
+
+
+ + Dentro do Prazo +
+
{estatisticas.dentroDoPrazo}
+
+ +
+
+ + Fora do Prazo +
+
{estatisticas.foraDoPrazo}
+
+
+ +
+ Total: {estatisticas.totalRegistros} registros de {estatisticas.totalFuncionarios} funcionários +
+ {:else} +
Carregando estatísticas...
+ {/if} + + +
+
+ diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts new file mode 100644 index 0000000..5d939fc --- /dev/null +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -0,0 +1,397 @@ +import { getLocalIP } from './browserInfo'; + +export interface InformacoesDispositivo { + ipAddress?: string; + ipPublico?: string; + ipLocal?: string; + userAgent?: string; + browser?: string; + browserVersion?: string; + engine?: string; + sistemaOperacional?: string; + osVersion?: string; + arquitetura?: string; + plataforma?: string; + latitude?: number; + longitude?: number; + precisao?: number; + endereco?: string; + cidade?: string; + estado?: string; + pais?: string; + timezone?: string; + deviceType?: string; + deviceModel?: string; + screenResolution?: string; + coresTela?: number; + idioma?: string; + isMobile?: boolean; + isTablet?: boolean; + isDesktop?: boolean; + connectionType?: string; + memoryInfo?: string; +} + +/** + * Detecta informações do navegador + */ +function detectarNavegador(): { browser: string; browserVersion: string; engine: string } { + if (typeof navigator === 'undefined') { + return { browser: 'Desconhecido', browserVersion: '', engine: '' }; + } + + const ua = navigator.userAgent; + let browser = 'Desconhecido'; + let browserVersion = ''; + let engine = ''; + + // Detectar engine + if (ua.includes('Edg/')) { + engine = 'EdgeHTML'; + } else if (ua.includes('Chrome/')) { + engine = 'Blink'; + } else if (ua.includes('Firefox/')) { + engine = 'Gecko'; + } else if (ua.includes('Safari/') && !ua.includes('Chrome/')) { + engine = 'WebKit'; + } + + // Detectar navegador + if (ua.includes('Edg/')) { + browser = 'Edge'; + const match = ua.match(/Edg\/(\d+)/); + browserVersion = match ? match[1]! : ''; + } else if (ua.includes('Chrome/') && !ua.includes('Edg/')) { + browser = 'Chrome'; + const match = ua.match(/Chrome\/(\d+)/); + browserVersion = match ? match[1]! : ''; + } else if (ua.includes('Firefox/')) { + browser = 'Firefox'; + const match = ua.match(/Firefox\/(\d+)/); + browserVersion = match ? match[1]! : ''; + } else if (ua.includes('Safari/') && !ua.includes('Chrome/')) { + browser = 'Safari'; + const match = ua.match(/Version\/(\d+)/); + browserVersion = match ? match[1]! : ''; + } else if (ua.includes('Opera/') || ua.includes('OPR/')) { + browser = 'Opera'; + const match = ua.match(/(?:Opera|OPR)\/(\d+)/); + browserVersion = match ? match[1]! : ''; + } + + return { browser, browserVersion, engine }; +} + +/** + * Detecta informações do sistema operacional + */ +function detectarSistemaOperacional(): { + sistemaOperacional: string; + osVersion: string; + arquitetura: string; + plataforma: string; +} { + if (typeof navigator === 'undefined') { + return { + sistemaOperacional: 'Desconhecido', + osVersion: '', + arquitetura: '', + plataforma: '', + }; + } + + const ua = navigator.userAgent; + const platform = navigator.platform || ''; + let sistemaOperacional = 'Desconhecido'; + let osVersion = ''; + let arquitetura = ''; + const plataforma = platform; + + // Detectar OS + if (ua.includes('Windows NT')) { + sistemaOperacional = 'Windows'; + const match = ua.match(/Windows NT (\d+\.\d+)/); + if (match) { + const version = match[1]!; + const versions: Record = { + '10.0': '10/11', + '6.3': '8.1', + '6.2': '8', + '6.1': '7', + }; + osVersion = versions[version] || version; + } + } else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) { + sistemaOperacional = 'macOS'; + const match = ua.match(/Mac OS X (\d+[._]\d+)/); + if (match) { + osVersion = match[1]!.replace('_', '.'); + } + } else if (ua.includes('Linux')) { + sistemaOperacional = 'Linux'; + osVersion = 'Linux'; + } else if (ua.includes('Android')) { + sistemaOperacional = 'Android'; + const match = ua.match(/Android (\d+(?:\.\d+)?)/); + osVersion = match ? match[1]! : ''; + } else if (ua.includes('iPhone') || ua.includes('iPad')) { + sistemaOperacional = 'iOS'; + const match = ua.match(/OS (\d+[._]\d+)/); + if (match) { + osVersion = match[1]!.replace('_', '.'); + } + } + + // Detectar arquitetura (se disponível) + if ('cpuClass' in navigator) { + arquitetura = (navigator as unknown as { cpuClass: string }).cpuClass; + } + + return { sistemaOperacional, osVersion, arquitetura, plataforma }; +} + +/** + * Detecta tipo de dispositivo + */ +function detectarTipoDispositivo(): { + deviceType: string; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; +} { + if (typeof navigator === 'undefined') { + return { + deviceType: 'Desconhecido', + isMobile: false, + isTablet: false, + isDesktop: true, + }; + } + + const ua = navigator.userAgent; + const isMobile = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); + const isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(ua); + const isDesktop = !isMobile && !isTablet; + + let deviceType = 'Desktop'; + if (isTablet) { + deviceType = 'Tablet'; + } else if (isMobile) { + deviceType = 'Mobile'; + } + + return { deviceType, isMobile, isTablet, isDesktop }; +} + +/** + * Obtém informações da tela + */ +function obterInformacoesTela(): { screenResolution: string; coresTela: number } { + if (typeof screen === 'undefined') { + return { screenResolution: 'Desconhecido', coresTela: 0 }; + } + + const screenResolution = `${screen.width}x${screen.height}`; + const coresTela = screen.colorDepth || 24; + + return { screenResolution, coresTela }; +} + +/** + * Obtém informações de conexão + */ +async function obterInformacoesConexao(): Promise { + if (typeof navigator === 'undefined' || !('connection' in navigator)) { + return 'Desconhecido'; + } + + const connection = (navigator as unknown as { connection?: { effectiveType?: string } }).connection; + if (connection?.effectiveType) { + return connection.effectiveType; + } + + return 'Desconhecido'; +} + +/** + * Obtém informações de memória (se disponível) + */ +function obterInformacoesMemoria(): string { + if (typeof navigator === 'undefined' || !('deviceMemory' in navigator)) { + return 'Desconhecido'; + } + + const deviceMemory = (navigator as unknown as { deviceMemory?: number }).deviceMemory; + if (deviceMemory) { + return `${deviceMemory} GB`; + } + + return 'Desconhecido'; +} + +/** + * Obtém localização via GPS + */ +async function obterLocalizacao(): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + endereco?: string; + cidade?: string; + estado?: string; + pais?: string; +}> { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return {}; + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve({}); + }, 10000); // Timeout de 10 segundos + + navigator.geolocation.getCurrentPosition( + async (position) => { + clearTimeout(timeout); + const { latitude, longitude, accuracy } = position.coords; + + // Tentar obter endereço via reverse geocoding + let endereco = ''; + let cidade = ''; + let estado = ''; + let pais = ''; + + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1` + ); + if (response.ok) { + const data = (await response.json()) as { + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + state?: string; + country?: string; + }; + }; + if (data.address) { + const addr = data.address; + if (addr.road) { + endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; + } + cidade = addr.city || addr.town || ''; + estado = addr.state || ''; + pais = addr.country || ''; + } + } + } catch (error) { + console.warn('Erro ao obter endereço:', error); + } + + resolve({ + latitude, + longitude, + precisao: accuracy, + endereco, + cidade, + estado, + pais, + }); + }, + (error) => { + clearTimeout(timeout); + console.warn('Erro ao obter localização:', error); + resolve({}); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + } + ); + }); +} + +/** + * Obtém IP público + */ +async function obterIPPublico(): Promise { + try { + const response = await fetch('https://api.ipify.org?format=json'); + if (response.ok) { + const data = (await response.json()) as { ip: string }; + return data.ip; + } + } catch (error) { + console.warn('Erro ao obter IP público:', error); + } + return undefined; +} + +/** + * Obtém todas as informações do dispositivo + */ +export async function obterInformacoesDispositivo(): Promise { + const informacoes: InformacoesDispositivo = {}; + + // Informações básicas + if (typeof navigator !== 'undefined') { + informacoes.userAgent = navigator.userAgent; + informacoes.idioma = navigator.language || navigator.languages?.[0]; + informacoes.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } + + // Informações do navegador + const navegador = detectarNavegador(); + informacoes.browser = navegador.browser; + informacoes.browserVersion = navegador.browserVersion; + informacoes.engine = navegador.engine; + + // Informações do sistema + const sistema = detectarSistemaOperacional(); + informacoes.sistemaOperacional = sistema.sistemaOperacional; + informacoes.osVersion = sistema.osVersion; + informacoes.arquitetura = sistema.arquitetura; + informacoes.plataforma = sistema.plataforma; + + // Tipo de dispositivo + const dispositivo = detectarTipoDispositivo(); + informacoes.deviceType = dispositivo.deviceType; + informacoes.isMobile = dispositivo.isMobile; + informacoes.isTablet = dispositivo.isTablet; + informacoes.isDesktop = dispositivo.isDesktop; + + // Informações da tela + const tela = obterInformacoesTela(); + informacoes.screenResolution = tela.screenResolution; + informacoes.coresTela = tela.coresTela; + + // Informações de conexão e memória (assíncronas) + const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao] = await Promise.all([ + obterInformacoesConexao(), + Promise.resolve(obterInformacoesMemoria()), + obterIPPublico(), + getLocalIP(), + obterLocalizacao(), + ]); + + informacoes.connectionType = connectionType; + informacoes.memoryInfo = memoryInfo; + informacoes.ipPublico = ipPublico; + informacoes.ipLocal = ipLocal; + informacoes.latitude = localizacao.latitude; + informacoes.longitude = localizacao.longitude; + informacoes.precisao = localizacao.precisao; + informacoes.endereco = localizacao.endereco; + informacoes.cidade = localizacao.cidade; + informacoes.estado = localizacao.estado; + informacoes.pais = localizacao.pais; + + // IP address (usar público se disponível, senão local) + informacoes.ipAddress = ipPublico || ipLocal; + + return informacoes; +} + diff --git a/apps/web/src/lib/utils/ponto.ts b/apps/web/src/lib/utils/ponto.ts new file mode 100644 index 0000000..a9b60cf --- /dev/null +++ b/apps/web/src/lib/utils/ponto.ts @@ -0,0 +1,103 @@ +/** + * Formata hora no formato HH:mm + */ +export function formatarHoraPonto(hora: number, minuto: number): string { + return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`; +} + +/** + * Formata data e hora completa + */ +export function formatarDataHoraCompleta( + data: string, + hora: number, + minuto: number, + segundo: number +): string { + const dataObj = new Date(`${data}T${formatarHoraPonto(hora, minuto)}:${segundo.toString().padStart(2, '0')}`); + return dataObj.toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +/** + * Calcula tempo trabalhado entre dois registros + */ +export function calcularTempoTrabalhado( + horaInicio: number, + minutoInicio: number, + horaFim: number, + minutoFim: number +): { horas: number; minutos: number } { + const minutosInicio = horaInicio * 60 + minutoInicio; + const minutosFim = horaFim * 60 + minutoFim; + const diferencaMinutos = minutosFim - minutosInicio; + + if (diferencaMinutos < 0) { + return { horas: 0, minutos: 0 }; + } + + const horas = Math.floor(diferencaMinutos / 60); + const minutos = diferencaMinutos % 60; + + return { horas, minutos }; +} + +/** + * Verifica se está dentro do prazo baseado na configuração + */ +export function verificarDentroDoPrazo( + hora: number, + minuto: number, + horarioConfigurado: string, + toleranciaMinutos: number +): boolean { + const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number); + const totalMinutosRegistro = hora * 60 + minuto; + const totalMinutosConfigurado = horaConfig * 60 + minutoConfig; + const diferenca = totalMinutosRegistro - totalMinutosConfigurado; + return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos; +} + +/** + * Obtém label do tipo de registro + */ +export function getTipoRegistroLabel(tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'): string { + const labels: Record = { + entrada: 'Entrada', + saida_almoco: 'Saída para Almoço', + retorno_almoco: 'Retorno do Almoço', + saida: 'Saída', + }; + return labels[tipo] || tipo; +} + +/** + * Obtém próximo tipo de registro esperado + */ +export function getProximoTipoRegistro( + ultimoTipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' | null +): 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' { + if (!ultimoTipo) { + return 'entrada'; + } + + switch (ultimoTipo) { + case 'entrada': + return 'saida_almoco'; + case 'saida_almoco': + return 'retorno_almoco'; + case 'retorno_almoco': + return 'saida'; + case 'saida': + return 'entrada'; // Novo dia + default: + return 'entrada'; + } +} + diff --git a/apps/web/src/lib/utils/sincronizacaoTempo.ts b/apps/web/src/lib/utils/sincronizacaoTempo.ts new file mode 100644 index 0000000..8e62b03 --- /dev/null +++ b/apps/web/src/lib/utils/sincronizacaoTempo.ts @@ -0,0 +1,56 @@ +import { api } from '@sgse-app/backend/convex/_generated/api'; +import type { ConvexClient } from 'convex/browser'; + +/** + * Obtém tempo do servidor (sincronizado) + */ +export async function obterTempoServidor(client: ConvexClient): Promise { + try { + // Tentar obter configuração e sincronizar se necessário + const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + + if (config.usarServidorExterno) { + try { + const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); + if (resultado.sucesso && resultado.timestamp) { + return resultado.timestamp; + } + } catch (error) { + console.warn('Erro ao sincronizar com servidor externo:', error); + if (config.fallbackParaPC) { + return Date.now(); + } + throw error; + } + } + + // Usar tempo do servidor Convex + const tempoServidor = await client.query(api.configuracaoRelogio.obterTempoServidor, {}); + return tempoServidor.timestamp; + } catch (error) { + console.warn('Erro ao obter tempo do servidor, usando tempo local:', error); + return Date.now(); + } +} + +/** + * Obtém tempo do PC (fallback) + */ +export function obterTempoPC(): number { + return Date.now(); +} + +/** + * Calcula offset entre dois timestamps + */ +export function calcularOffset(timestampServidor: number, timestampLocal: number): number { + return timestampServidor - timestampLocal; +} + +/** + * Aplica offset a um timestamp + */ +export function aplicarOffset(timestamp: number, offsetSegundos: number): number { + return timestamp + offsetSegundos * 1000; +} + diff --git a/apps/web/src/lib/utils/webcam.ts b/apps/web/src/lib/utils/webcam.ts new file mode 100644 index 0000000..c98296c --- /dev/null +++ b/apps/web/src/lib/utils/webcam.ts @@ -0,0 +1,150 @@ +/** + * Verifica se webcam está disponível + */ +export async function validarWebcamDisponivel(): Promise { + if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return false; + } + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some((device) => device.kind === 'videoinput'); + } catch { + return false; + } +} + +/** + * Captura imagem da webcam + */ +export async function capturarWebcam(): Promise { + if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return null; + } + + let stream: MediaStream | null = null; + + try { + // Solicitar acesso à webcam + stream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + facingMode: 'user', + }, + }); + + // Criar elemento de vídeo temporário + const video = document.createElement('video'); + video.srcObject = stream; + video.play(); + + // Aguardar vídeo estar pronto + await new Promise((resolve, reject) => { + video.onloadedmetadata = () => { + video.width = video.videoWidth; + video.height = video.videoHeight; + resolve(); + }; + video.onerror = reject; + setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000); + }); + + // Capturar frame + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Não foi possível obter contexto do canvas'); + } + + ctx.drawImage(video, 0, 0); + + // Converter para blob + return await new Promise((resolve) => { + canvas.toBlob( + (blob) => { + resolve(blob); + }, + 'image/jpeg', + 0.9 + ); + }); + } catch (error) { + console.error('Erro ao capturar webcam:', error); + return null; + } finally { + // Parar stream + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + } +} + +/** + * Captura imagem da webcam com preview + */ +export async function capturarWebcamComPreview( + videoElement: HTMLVideoElement, + canvasElement: HTMLCanvasElement +): Promise { + if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return null; + } + + let stream: MediaStream | null = null; + + try { + // Solicitar acesso à webcam + stream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + facingMode: 'user', + }, + }); + + videoElement.srcObject = stream; + await videoElement.play(); + + // Aguardar vídeo estar pronto + await new Promise((resolve, reject) => { + videoElement.onloadedmetadata = () => { + canvasElement.width = videoElement.videoWidth; + canvasElement.height = videoElement.videoHeight; + resolve(); + }; + videoElement.onerror = reject; + setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000); + }); + + // Capturar frame + const ctx = canvasElement.getContext('2d'); + if (!ctx) { + throw new Error('Não foi possível obter contexto do canvas'); + } + + ctx.drawImage(videoElement, 0, 0); + + // Converter para blob + return await new Promise((resolve) => { + canvasElement.toBlob( + (blob) => { + resolve(blob); + }, + 'image/jpeg', + 0.9 + ); + }); + } catch (error) { + console.error('Erro ao capturar webcam:', error); + return null; + } finally { + // Parar stream + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } + } +} + diff --git a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte index 74d5f63..ce0a86b 100644 --- a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte @@ -1,5 +1,6 @@ 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 new file mode 100644 index 0000000..d88081a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -0,0 +1,355 @@ + + +
+ +
+
+
+ +
+
+

Registro de Pontos

+

Gerencie e visualize os registros de ponto dos funcionários

+
+
+
+ + +
+
+

+ + Filtros +

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + {#if estatisticas} +
+
+
+ +
+
Total de Registros
+
{estatisticas.totalRegistros}
+
+ +
+
+ +
+
Dentro do Prazo
+
{estatisticas.dentroDoPrazo}
+
+ {estatisticas.totalRegistros > 0 + ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% +
+
+ +
+
+ +
+
Fora do Prazo
+
{estatisticas.foraDoPrazo}
+
+ {estatisticas.totalRegistros > 0 + ? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% +
+
+ +
+
+ +
+
Funcionários
+
{estatisticas.totalFuncionarios}
+
+ {estatisticas.funcionariosDentroPrazo} dentro do prazo, {estatisticas.funcionariosForaPrazo} fora +
+
+
+ {/if} + + +
+
+

Registros

+ + {#if registrosAgrupados.length === 0} +
+ Nenhum registro encontrado para o período selecionado +
+ {:else} +
+ {#each registrosAgrupados as grupo} +
+
+
+
+

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

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

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

+ {/if} +
+ +
+ +
+ + + + + + + + + + + {#each grupo.registros as registro} + + + + + + + {/each} + +
DataTipoHorárioStatus
{registro.data}{getTipoRegistroLabel(registro.tipo)}{formatarHoraPonto(registro.hora, registro.minuto)} + + {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} + +
+
+
+
+ {/each} +
+ {/if} +
+
+
+ diff --git a/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte b/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte index a921bf1..4b8a0ad 100644 --- a/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte +++ b/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte @@ -1,5 +1,6 @@ + +
+ +
+
+
+ +
+
+

Configurações de Ponto

+

Configure os horários de trabalho e tolerâncias

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

Horários de Trabalho

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ + +
+ + +
+ Tempo de tolerância para registros antes ou depois do horário configurado +
+
+ + +
+ +
+
+
+
+ diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte new file mode 100644 index 0000000..b913e0a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte @@ -0,0 +1,250 @@ + + +
+ +
+
+
+ +
+
+

Configurações de Relógio

+

Configure a sincronização de tempo do sistema

+
+
+
+ + + {#if mensagem} +
+ {#if mensagem.tipo === 'success'} + + {:else} + + {/if} + {mensagem.texto} +
+ {/if} + + +
+
+

Sincronização de Tempo

+ + +
+ +
+ Sincronizar com servidor de tempo externo (NTP) em vez de usar o relógio do PC +
+
+ + {#if usarServidorExterno} +
+ +
+ + +
+ Ex: pool.ntp.org, time.google.com, time.windows.com +
+
+ + +
+ + +
+ Porta padrão: 123 +
+
+
+ {/if} + + +
+ +
+ Se marcado, o sistema usará o relógio do PC caso não consiga sincronizar com o servidor + externo +
+
+ + +
+ {#if usarServidorExterno} + + {/if} + + +
+
+
+ + +
+
+

Informações

+
+ +
+

+ Nota: O sistema usa uma API HTTP para sincronização de tempo como + aproximação do protocolo NTP. Para sincronização NTP real, seria necessário uma biblioteca + específica. +

+

+ Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com +

+
+
+
+
+
+ diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 7312fc7..8e8b485 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -21,6 +21,8 @@ import type * as auth_utils from "../auth/utils.js"; import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; +import type * as configuracaoPonto from "../configuracaoPonto.js"; +import type * as configuracaoRelogio from "../configuracaoRelogio.js"; import type * as crons from "../crons.js"; import type * as cursos from "../cursos.js"; import type * as dashboard from "../dashboard.js"; @@ -35,6 +37,7 @@ import type * as logsAtividades from "../logsAtividades.js"; import type * as logsLogin from "../logsLogin.js"; import type * as monitoramento from "../monitoramento.js"; import type * as permissoesAcoes from "../permissoesAcoes.js"; +import type * as pontos from "../pontos.js"; import type * as preferenciasNotificacao from "../preferenciasNotificacao.js"; import type * as pushNotifications from "../pushNotifications.js"; import type * as roles from "../roles.js"; @@ -70,6 +73,8 @@ declare const fullApi: ApiFromModules<{ chamados: typeof chamados; chat: typeof chat; configuracaoEmail: typeof configuracaoEmail; + configuracaoPonto: typeof configuracaoPonto; + configuracaoRelogio: typeof configuracaoRelogio; crons: typeof crons; cursos: typeof cursos; dashboard: typeof dashboard; @@ -84,6 +89,7 @@ declare const fullApi: ApiFromModules<{ logsLogin: typeof logsLogin; monitoramento: typeof monitoramento; permissoesAcoes: typeof permissoesAcoes; + pontos: typeof pontos; preferenciasNotificacao: typeof preferenciasNotificacao; pushNotifications: typeof pushNotifications; roles: typeof roles; diff --git a/packages/backend/convex/configuracaoPonto.ts b/packages/backend/convex/configuracaoPonto.ts new file mode 100644 index 0000000..055a09d --- /dev/null +++ b/packages/backend/convex/configuracaoPonto.ts @@ -0,0 +1,126 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import type { Id } from './_generated/dataModel'; + +/** + * Valida formato de horário HH:mm + */ +function validarHorario(horario: string): boolean { + const regex = /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/; + return regex.test(horario); +} + +/** + * Obtém a configuração ativa de ponto + */ +export const obterConfiguracao = query({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + // Retornar configuração padrão se não houver + return { + horarioEntrada: '08:00', + horarioSaidaAlmoco: '12:00', + horarioRetornoAlmoco: '13:00', + horarioSaida: '17:00', + toleranciaMinutos: 15, + ativo: false, + }; + } + + return config; + }, +}); + +/** + * Salva configuração de ponto (apenas TI) + */ +export const salvarConfiguracao = mutation({ + args: { + horarioEntrada: v.string(), + horarioSaidaAlmoco: v.string(), + horarioRetornoAlmoco: v.string(), + horarioSaida: v.string(), + toleranciaMinutos: v.number(), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar se usuário tem permissão de TI + // Por enquanto, permitir se tiver roleId + + // Validar horários + if (!validarHorario(args.horarioEntrada)) { + throw new Error('Horário de entrada inválido (formato: HH:mm)'); + } + if (!validarHorario(args.horarioSaidaAlmoco)) { + throw new Error('Horário de saída para almoço inválido (formato: HH:mm)'); + } + if (!validarHorario(args.horarioRetornoAlmoco)) { + throw new Error('Horário de retorno do almoço inválido (formato: HH:mm)'); + } + if (!validarHorario(args.horarioSaida)) { + throw new Error('Horário de saída inválido (formato: HH:mm)'); + } + + // Validar tolerância + if (args.toleranciaMinutos < 0 || args.toleranciaMinutos > 60) { + throw new Error('Tolerância deve estar entre 0 e 60 minutos'); + } + + // Validar sequência lógica de horários + const [horaEntrada, minutoEntrada] = args.horarioEntrada.split(':').map(Number); + const [horaSaidaAlmoco, minutoSaidaAlmoco] = args.horarioSaidaAlmoco.split(':').map(Number); + const [horaRetornoAlmoco, minutoRetornoAlmoco] = args.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaida, minutoSaida] = args.horarioSaida.split(':').map(Number); + + const minutosEntrada = horaEntrada * 60 + minutoEntrada; + const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco; + const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco; + const minutosSaida = horaSaida * 60 + minutoSaida; + + if (minutosEntrada >= minutosSaidaAlmoco) { + throw new Error('Horário de entrada deve ser anterior à saída para almoço'); + } + if (minutosSaidaAlmoco >= minutosRetornoAlmoco) { + throw new Error('Horário de saída para almoço deve ser anterior ao retorno'); + } + if (minutosRetornoAlmoco >= minutosSaida) { + throw new Error('Horário de retorno do almoço deve ser anterior à saída'); + } + + // Desativar configurações antigas + const configsAntigas = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + + for (const configAntiga of configsAntigas) { + await ctx.db.patch(configAntiga._id, { ativo: false }); + } + + // Criar nova configuração + const configId = await ctx.db.insert('configuracaoPonto', { + horarioEntrada: args.horarioEntrada, + horarioSaidaAlmoco: args.horarioSaidaAlmoco, + horarioRetornoAlmoco: args.horarioRetornoAlmoco, + horarioSaida: args.horarioSaida, + toleranciaMinutos: args.toleranciaMinutos, + ativo: true, + atualizadoPor: usuario._id as Id<'usuarios'>, + atualizadoEm: Date.now(), + }); + + return { configId }; + }, +}); + diff --git a/packages/backend/convex/configuracaoRelogio.ts b/packages/backend/convex/configuracaoRelogio.ts new file mode 100644 index 0000000..bea27a4 --- /dev/null +++ b/packages/backend/convex/configuracaoRelogio.ts @@ -0,0 +1,202 @@ +import { v } from 'convex/values'; +import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import type { Id } from './_generated/dataModel'; +import { api, internal } from './_generated/api'; + +/** + * Obtém a configuração do relógio + */ +export const obterConfiguracao = query({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query('configuracaoRelogio') + .withIndex('by_ativo', (q) => q.eq('usarServidorExterno', true)) + .first(); + + if (!config) { + // Retornar configuração padrão + return { + servidorNTP: 'pool.ntp.org', + portaNTP: 123, + usarServidorExterno: false, + fallbackParaPC: true, + ultimaSincronizacao: null, + offsetSegundos: null, + }; + } + + return config; + }, +}); + +/** + * Salva configuração do relógio (apenas TI) + */ +export const salvarConfiguracao = mutation({ + args: { + servidorNTP: v.optional(v.string()), + portaNTP: v.optional(v.number()), + usarServidorExterno: v.boolean(), + fallbackParaPC: v.boolean(), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar se usuário tem permissão de TI + + // Validar servidor NTP se usar servidor externo + if (args.usarServidorExterno) { + if (!args.servidorNTP || args.servidorNTP.trim() === '') { + throw new Error('Servidor NTP é obrigatório quando usar servidor externo'); + } + if (!args.portaNTP || args.portaNTP < 1 || args.portaNTP > 65535) { + throw new Error('Porta NTP deve estar entre 1 e 65535'); + } + } + + // Buscar configuração existente + const configExistente = await ctx.db + .query('configuracaoRelogio') + .withIndex('by_ativo', (q) => q.eq('usarServidorExterno', args.usarServidorExterno)) + .first(); + + if (configExistente) { + // Atualizar configuração existente + await ctx.db.patch(configExistente._id, { + servidorNTP: args.servidorNTP, + portaNTP: args.portaNTP, + usarServidorExterno: args.usarServidorExterno, + fallbackParaPC: args.fallbackParaPC, + atualizadoPor: usuario._id as Id<'usuarios'>, + atualizadoEm: Date.now(), + }); + return { configId: configExistente._id }; + } else { + // Criar nova configuração + const configId = await ctx.db.insert('configuracaoRelogio', { + servidorNTP: args.servidorNTP, + portaNTP: args.portaNTP, + usarServidorExterno: args.usarServidorExterno, + fallbackParaPC: args.fallbackParaPC, + atualizadoPor: usuario._id as Id<'usuarios'>, + atualizadoEm: Date.now(), + }); + return { configId }; + } + }, +}); + +/** + * Obtém tempo do servidor (timestamp atual) + */ +export const obterTempoServidor = query({ + args: {}, + handler: async () => { + return { + timestamp: Date.now(), + data: new Date().toISOString(), + }; + }, +}); + +/** + * Sincroniza tempo com servidor NTP (via action) + * Nota: NTP real requer biblioteca específica, aqui fazemos uma aproximação + */ +export const sincronizarTempo = action({ + args: {}, + handler: async (ctx) => { + // Buscar configuração diretamente do banco usando query pública + const config = await ctx.runQuery(api.configuracaoRelogio.obterConfiguracao, {}); + + if (!config.usarServidorExterno) { + return { + sucesso: true, + timestamp: Date.now(), + usandoServidorExterno: false, + offsetSegundos: 0, + }; + } + + // Tentar obter tempo de um servidor NTP público via HTTP + // Nota: Esta é uma aproximação. Para NTP real, seria necessário usar uma biblioteca específica + try { + // Usar API pública de tempo como fallback + const response = await fetch('https://worldtimeapi.org/api/timezone/America/Recife'); + if (!response.ok) { + throw new Error('Falha ao obter tempo do servidor'); + } + + const data = (await response.json()) as { datetime: string }; + const serverTime = new Date(data.datetime).getTime(); + const localTime = Date.now(); + const offsetSegundos = Math.floor((serverTime - localTime) / 1000); + + // Atualizar configuração com offset + // Buscar configuração diretamente usando query interna + const configs = await ctx.runQuery(internal.configuracaoRelogio.listarConfiguracoes, {}); + const configExistente = configs.find( + (c: { usarServidorExterno: boolean; _id: Id<'configuracaoRelogio'> }) => + c.usarServidorExterno === config.usarServidorExterno + ); + if (configExistente) { + await ctx.runMutation(internal.configuracaoRelogio.atualizarSincronizacao, { + configId: configExistente._id, + offsetSegundos, + }); + } + + return { + sucesso: true, + timestamp: serverTime, + usandoServidorExterno: true, + offsetSegundos, + }; + } catch { + // Se falhar e fallbackParaPC estiver ativo, usar tempo local + if (config.fallbackParaPC) { + return { + sucesso: true, + timestamp: Date.now(), + usandoServidorExterno: false, + offsetSegundos: 0, + aviso: 'Falha ao sincronizar com servidor externo, usando relógio do PC', + }; + } + + throw new Error('Falha ao sincronizar tempo e fallback desabilitado'); + } + }, +}); + +/** + * Lista configurações (internal) + */ +export const listarConfiguracoes = internalQuery({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('configuracaoRelogio').collect(); + }, +}); + +/** + * Atualiza informações de sincronização (internal) + */ +export const atualizarSincronizacao = internalMutation({ + args: { + configId: v.id('configuracaoRelogio'), + offsetSegundos: v.number(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.configId, { + ultimaSincronizacao: Date.now(), + offsetSegundos: args.offsetSegundos, + }); + }, +}); + diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts new file mode 100644 index 0000000..8165a5d --- /dev/null +++ b/packages/backend/convex/pontos.ts @@ -0,0 +1,442 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import type { Id } from './_generated/dataModel'; + +/** + * Gera URL para upload de imagem do ponto + */ +export const generateUploadUrl = mutation({ + args: {}, + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + return await ctx.storage.generateUploadUrl(); + }, +}); + +interface InformacoesDispositivo { + ipAddress?: string; + ipPublico?: string; + ipLocal?: string; + userAgent?: string; + browser?: string; + browserVersion?: string; + engine?: string; + sistemaOperacional?: string; + osVersion?: string; + arquitetura?: string; + plataforma?: string; + latitude?: number; + longitude?: number; + precisao?: number; + endereco?: string; + cidade?: string; + estado?: string; + pais?: string; + timezone?: string; + deviceType?: string; + deviceModel?: string; + screenResolution?: string; + coresTela?: number; + idioma?: string; + isMobile?: boolean; + isTablet?: boolean; + isDesktop?: boolean; + connectionType?: string; + memoryInfo?: string; +} + +/** + * Calcula se o registro está dentro do prazo baseado na configuração + */ +function calcularStatusPonto( + hora: number, + minuto: number, + horarioConfigurado: string, + toleranciaMinutos: number +): boolean { + const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number); + const totalMinutosRegistro = hora * 60 + minuto; + const totalMinutosConfigurado = horaConfig * 60 + minutoConfig; + const diferenca = totalMinutosRegistro - totalMinutosConfigurado; + return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos; +} + +/** + * Determina o tipo de registro baseado na sequência lógica + */ +async function determinarTipoRegistro( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string +): Promise<'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'> { + const registrosHoje = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .order('desc') + .collect(); + + if (registrosHoje.length === 0) { + return 'entrada'; + } + + const ultimoRegistro = registrosHoje[0]; + switch (ultimoRegistro.tipo) { + case 'entrada': + return 'saida_almoco'; + case 'saida_almoco': + return 'retorno_almoco'; + case 'retorno_almoco': + return 'saida'; + case 'saida': + // Se já saiu, próximo registro é entrada (novo dia) + return 'entrada'; + default: + return 'entrada'; + } +} + +/** + * Registra um ponto (entrada, saída, etc.) + */ +export const registrarPonto = mutation({ + args: { + imagemId: v.optional(v.id('_storage')), + informacoesDispositivo: v.optional( + v.object({ + ipAddress: v.optional(v.string()), + ipPublico: v.optional(v.string()), + ipLocal: v.optional(v.string()), + userAgent: v.optional(v.string()), + browser: v.optional(v.string()), + browserVersion: v.optional(v.string()), + engine: v.optional(v.string()), + sistemaOperacional: v.optional(v.string()), + osVersion: v.optional(v.string()), + arquitetura: v.optional(v.string()), + plataforma: v.optional(v.string()), + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), + precisao: v.optional(v.number()), + endereco: v.optional(v.string()), + cidade: v.optional(v.string()), + estado: v.optional(v.string()), + pais: v.optional(v.string()), + timezone: v.optional(v.string()), + deviceType: v.optional(v.string()), + deviceModel: v.optional(v.string()), + screenResolution: v.optional(v.string()), + coresTela: v.optional(v.number()), + idioma: v.optional(v.string()), + isMobile: v.optional(v.boolean()), + isTablet: v.optional(v.boolean()), + isDesktop: v.optional(v.boolean()), + connectionType: v.optional(v.string()), + memoryInfo: v.optional(v.string()), + }) + ), + timestamp: v.number(), + sincronizadoComServidor: v.boolean(), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + if (!usuario.funcionarioId) { + throw new Error('Usuário não possui funcionário associado'); + } + + // Verificar se funcionário está ativo + const funcionario = await ctx.db.get(usuario.funcionarioId); + if (!funcionario) { + throw new Error('Funcionário não encontrado'); + } + + // Obter configuração de ponto + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + throw new Error('Configuração de ponto não encontrada'); + } + + // Converter timestamp para data/hora + const dataObj = new Date(args.timestamp); + const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD + const hora = dataObj.getHours(); + const minuto = dataObj.getMinutes(); + const segundo = dataObj.getSeconds(); + + // Verificar se já existe registro no mesmo minuto + const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined + const registrosMinuto = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .collect(); + + const registroDuplicado = registrosMinuto.find( + (r) => r.hora === hora && r.minuto === minuto + ); + + if (registroDuplicado) { + throw new Error('Já existe um registro neste minuto'); + } + + // Determinar tipo de registro + const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data); + + // Calcular horário esperado e tolerância + let horarioConfigurado = ''; + switch (tipo) { + case 'entrada': + horarioConfigurado = config.horarioEntrada; + break; + case 'saida_almoco': + horarioConfigurado = config.horarioSaidaAlmoco; + break; + case 'retorno_almoco': + horarioConfigurado = config.horarioRetornoAlmoco; + break; + case 'saida': + horarioConfigurado = config.horarioSaida; + break; + } + + const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos); + + // Criar registro + const registroId = await ctx.db.insert('registrosPonto', { + funcionarioId: usuario.funcionarioId, + tipo, + data, + hora, + minuto, + segundo, + timestamp: args.timestamp, + imagemId: args.imagemId, + sincronizadoComServidor: args.sincronizadoComServidor, + toleranciaMinutos: config.toleranciaMinutos, + dentroDoPrazo, + ipAddress: args.informacoesDispositivo?.ipAddress, + ipPublico: args.informacoesDispositivo?.ipPublico, + ipLocal: args.informacoesDispositivo?.ipLocal, + userAgent: args.informacoesDispositivo?.userAgent, + browser: args.informacoesDispositivo?.browser, + browserVersion: args.informacoesDispositivo?.browserVersion, + engine: args.informacoesDispositivo?.engine, + sistemaOperacional: args.informacoesDispositivo?.sistemaOperacional, + osVersion: args.informacoesDispositivo?.osVersion, + arquitetura: args.informacoesDispositivo?.arquitetura, + plataforma: args.informacoesDispositivo?.plataforma, + latitude: args.informacoesDispositivo?.latitude, + longitude: args.informacoesDispositivo?.longitude, + precisao: args.informacoesDispositivo?.precisao, + endereco: args.informacoesDispositivo?.endereco, + cidade: args.informacoesDispositivo?.cidade, + estado: args.informacoesDispositivo?.estado, + pais: args.informacoesDispositivo?.pais, + timezone: args.informacoesDispositivo?.timezone, + deviceType: args.informacoesDispositivo?.deviceType, + deviceModel: args.informacoesDispositivo?.deviceModel, + screenResolution: args.informacoesDispositivo?.screenResolution, + coresTela: args.informacoesDispositivo?.coresTela, + idioma: args.informacoesDispositivo?.idioma, + isMobile: args.informacoesDispositivo?.isMobile, + isTablet: args.informacoesDispositivo?.isTablet, + isDesktop: args.informacoesDispositivo?.isDesktop, + connectionType: args.informacoesDispositivo?.connectionType, + memoryInfo: args.informacoesDispositivo?.memoryInfo, + criadoEm: Date.now(), + }); + + return { registroId, tipo, dentroDoPrazo }; + }, +}); + +/** + * Lista registros do dia atual do funcionário + */ +export const listarRegistrosDia = query({ + args: { + data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario || !usuario.funcionarioId) { + return []; + } + + const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined + const data = args.data || new Date().toISOString().split('T')[0]!; + + const registros = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .order('asc') + .collect(); + + return registros; + }, +}); + +/** + * Lista registros por período (para RH) + */ +export const listarRegistrosPeriodo = query({ + args: { + funcionarioId: v.optional(v.id('funcionarios')), + dataInicio: v.string(), // YYYY-MM-DD + dataFim: v.string(), // YYYY-MM-DD + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar permissão (RH ou TI) + // Por enquanto, permitir se tiver funcionarioId ou for admin + // TODO: Implementar verificação de permissão adequada + + const dataInicio = new Date(args.dataInicio); + const dataFim = new Date(args.dataFim); + dataFim.setHours(23, 59, 59, 999); + + const registros = await ctx.db + .query('registrosPonto') + .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) + .collect(); + + // Filtrar por funcionário se especificado + let registrosFiltrados = registros; + if (args.funcionarioId) { + registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId); + } + + // Buscar informações dos funcionários + const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId)); + const funcionarios = await Promise.all( + Array.from(funcionariosIds).map((id) => ctx.db.get(id)) + ); + + return registrosFiltrados.map((registro) => { + const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId); + return { + ...registro, + funcionario: funcionario + ? { + nome: funcionario.nome, + matricula: funcionario.matricula, + descricaoCargo: funcionario.descricaoCargo, + } + : null, + }; + }); + }, +}); + +/** + * Obtém estatísticas de pontos (para gráficos) + */ +export const obterEstatisticas = query({ + args: { + dataInicio: v.string(), // YYYY-MM-DD + dataFim: v.string(), // YYYY-MM-DD + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão (RH ou TI) + + const registros = await ctx.db + .query('registrosPonto') + .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) + .collect(); + + const totalRegistros = registros.length; + const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length; + const foraDoPrazo = totalRegistros - dentroDoPrazo; + + // Agrupar por funcionário + const funcionariosUnicos = new Set(registros.map((r) => r.funcionarioId)); + const totalFuncionarios = funcionariosUnicos.size; + + // Funcionários com registros dentro do prazo + const funcionariosDentroPrazo = new Set( + registros.filter((r) => r.dentroDoPrazo).map((r) => r.funcionarioId) + ).size; + + // Funcionários com registros fora do prazo + const funcionariosForaPrazo = totalFuncionarios - funcionariosDentroPrazo; + + return { + totalRegistros, + dentroDoPrazo, + foraDoPrazo, + totalFuncionarios, + funcionariosDentroPrazo, + funcionariosForaPrazo, + }; + }, +}); + +/** + * Obtém um registro específico (para comprovante) + */ +export const obterRegistro = query({ + args: { + registroId: v.id('registrosPonto'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const registro = await ctx.db.get(args.registroId); + if (!registro) { + throw new Error('Registro não encontrado'); + } + + // Verificar se o usuário tem permissão (próprio registro ou RH/TI) + if (registro.funcionarioId !== usuario.funcionarioId) { + // TODO: Verificar se é RH ou TI + // Por enquanto, permitir + } + + const funcionario = await ctx.db.get(registro.funcionarioId); + let simbolo = null; + if (funcionario) { + simbolo = await ctx.db.get(funcionario.simboloId); + } + + return { + ...registro, + funcionario: funcionario + ? { + nome: funcionario.nome, + matricula: funcionario.matricula, + descricaoCargo: funcionario.descricaoCargo, + simbolo: simbolo + ? { + nome: simbolo.nome, + tipo: simbolo.tipo, + } + : null, + } + : null, + }; + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 48daf96..472c3d7 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1325,5 +1325,96 @@ export default defineSchema({ criadoEm: v.number(), atualizadoEm: v.number(), }) - .index("by_criadoEm", ["criadoEm"]) + .index("by_criadoEm", ["criadoEm"]), + + // Sistema de Controle de Ponto + registrosPonto: defineTable({ + funcionarioId: v.id("funcionarios"), + tipo: v.union( + v.literal("entrada"), + v.literal("saida_almoco"), + v.literal("retorno_almoco"), + v.literal("saida") + ), + data: v.string(), // YYYY-MM-DD + hora: v.number(), + minuto: v.number(), + segundo: v.number(), + timestamp: v.number(), // Timestamp completo para ordenação + imagemId: v.optional(v.id("_storage")), + sincronizadoComServidor: v.boolean(), + toleranciaMinutos: v.number(), + dentroDoPrazo: v.boolean(), + + // Informações de Rede + ipAddress: v.optional(v.string()), + ipPublico: v.optional(v.string()), + ipLocal: v.optional(v.string()), + + // Informações do Navegador + userAgent: v.optional(v.string()), + browser: v.optional(v.string()), + browserVersion: v.optional(v.string()), + engine: v.optional(v.string()), + + // Informações do Sistema + sistemaOperacional: v.optional(v.string()), + osVersion: v.optional(v.string()), + arquitetura: v.optional(v.string()), + plataforma: v.optional(v.string()), + + // Informações de Localização + latitude: v.optional(v.number()), + longitude: v.optional(v.number()), + precisao: v.optional(v.number()), + endereco: v.optional(v.string()), + cidade: v.optional(v.string()), + estado: v.optional(v.string()), + pais: v.optional(v.string()), + timezone: v.optional(v.string()), + + // Informações do Dispositivo + deviceType: v.optional(v.string()), + deviceModel: v.optional(v.string()), + screenResolution: v.optional(v.string()), + coresTela: v.optional(v.number()), + idioma: v.optional(v.string()), + + // Informações Adicionais + isMobile: v.optional(v.boolean()), + isTablet: v.optional(v.boolean()), + isDesktop: v.optional(v.boolean()), + connectionType: v.optional(v.string()), + memoryInfo: v.optional(v.string()), + + criadoEm: v.number(), + }) + .index("by_funcionario_data", ["funcionarioId", "data"]) + .index("by_data", ["data"]) + .index("by_dentro_prazo", ["dentroDoPrazo", "data"]) + .index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]), + + configuracaoPonto: defineTable({ + horarioEntrada: v.string(), // HH:mm + horarioSaidaAlmoco: v.string(), // HH:mm + horarioRetornoAlmoco: v.string(), // HH:mm + horarioSaida: v.string(), // HH:mm + toleranciaMinutos: v.number(), + ativo: v.boolean(), + atualizadoPor: v.id("usuarios"), + atualizadoEm: v.number(), + }) + .index("by_ativo", ["ativo"]), + + configuracaoRelogio: defineTable({ + servidorNTP: v.optional(v.string()), + portaNTP: v.optional(v.number()), + usarServidorExterno: v.boolean(), + fallbackParaPC: v.boolean(), + ultimaSincronizacao: v.optional(v.number()), + offsetSegundos: v.optional(v.number()), + atualizadoPor: v.id("usuarios"), + atualizadoEm: v.number(), + }) + .index("by_ativo", ["usarServidorExterno"]) }); From b01d2d6786de42e6031389e30b9a1f2be1b24393 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 18 Nov 2025 15:28:26 -0300 Subject: [PATCH 2/8] feat: enhance point registration and management features - Added functionality to capture and display images during point registration, improving user experience. - Implemented error handling for image uploads and webcam access, ensuring smoother operation. - Introduced a justification field for point registration, allowing users to provide context for their entries. - Enhanced the backend to support new features, including image handling and justification storage. - Updated UI components for better layout and responsiveness, improving overall usability. --- .../components/ponto/ComprovantePonto.svelte | 105 +++++ .../lib/components/ponto/RegistroPonto.svelte | 396 +++++++++++++++--- .../lib/components/ponto/WebcamCapture.svelte | 180 +++++--- .../ponto/WidgetGestaoPontos.svelte | 108 ++--- apps/web/src/lib/utils/deviceInfo.ts | 8 +- .../(dashboard)/gestao-pessoas/+page.svelte | 2 +- .../registro-pontos/+page.svelte | 63 ++- .../secretaria-executiva/+page.svelte | 2 +- packages/backend/convex/pontos.ts | 245 ++++++++++- packages/backend/convex/schema.ts | 19 +- 10 files changed, 941 insertions(+), 187 deletions(-) diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index 3e149e9..cecf612 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -169,6 +169,91 @@ } } + // Imagem capturada (se disponível) + if (registro.imagemUrl) { + yPosition += 10; + // Verificar se precisa de nova página + 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 { + // Carregar imagem usando fetch para evitar problemas de CORS + const response = await fetch(registro.imagemUrl); + if (!response.ok) { + throw new Error('Erro ao carregar imagem'); + } + + const blob = await response.blob(); + const reader = new FileReader(); + + // Converter blob para base64 + const base64 = await new Promise((resolve, reject) => { + reader.onloadend = () => { + 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); + }); + + // Criar elemento de imagem para obter dimensões + 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); + }); + + // Calcular dimensões para caber na página (largura máxima 80mm, manter proporção) + 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) { + // Imagem horizontal + imgWidth = maxWidth; + imgHeight = maxWidth / aspectRatio; + } else { + // Imagem vertical + imgHeight = maxHeight; + imgWidth = maxHeight * aspectRatio; + } + } + + // Centralizar imagem + const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2; + + // Verificar se cabe na página atual + if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPosition = 20; + } + + // Adicionar imagem ao PDF usando base64 + doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight); + 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' }); + yPosition += 6; + } + } + // Rodapé const pageCount = doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { @@ -248,6 +333,26 @@ + + {#if registro.imagemUrl} +
+
+

Foto Capturada

+
+ Foto do registro de ponto { + console.error('Erro ao carregar imagem:', e); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+
+ {/if} +
- {:else} -
- - Foto capturada -
- {/if} - - + +
+ +
+ +
+ + {#if historicoSaldo && registrosOrdenados.length > 0} +
+
+

+ + Histórico do Dia +

+ + +
+
+
+

Saldo de Horas

+

+ {saldoFormatado} +

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

Carga Horária Diária: {Math.floor(historicoSaldo.cargaHorariaDiaria / 60)}h {historicoSaldo.cargaHorariaDiaria % 60}min

+

Horas Trabalhadas: {Math.floor(historicoSaldo.horasTrabalhadas / 60)}h {historicoSaldo.horasTrabalhadas % 60}min

+
+
+ + +
+
+

Registros Realizados

+
+ {#each registrosOrdenados as registro (registro._id)} +
+
+
+
+
+ {getTipoRegistroLabel(registro.tipo)} + {#if registro.dentroDoPrazo} + + {:else} + + {/if} +
+

+ {formatarHoraPonto(registro.hora, registro.minuto)} +

+ {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} +
+
+
+
+ {/each} +
+
+
+
+ {/if} + {#if mostrandoWebcam} - diff --git a/apps/web/src/lib/components/ponto/WebcamCapture.svelte b/apps/web/src/lib/components/ponto/WebcamCapture.svelte index ee3ac88..ff983ec 100644 --- a/apps/web/src/lib/components/ponto/WebcamCapture.svelte +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -4,11 +4,13 @@ import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam'; interface Props { - onCapture: (blob: Blob) => void; + onCapture: (blob: Blob | null) => void; onCancel: () => void; + onError?: () => void; + autoCapture?: boolean; } - let { onCapture, onCancel }: Props = $props(); + let { onCapture, onCancel, onError, autoCapture = false }: Props = $props(); let videoElement: HTMLVideoElement | null = $state(null); let canvasElement: HTMLCanvasElement | null = $state(null); @@ -19,9 +21,12 @@ let previewUrl = $state(null); onMount(async () => { - webcamDisponivel = await validarWebcamDisponivel(); - if (!webcamDisponivel) { - erro = 'Webcam não disponível'; + // Tentar obter permissão de webcam automaticamente + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + erro = 'Webcam não suportada'; + if (autoCapture && onError) { + onError(); + } return; } @@ -30,18 +35,36 @@ video: { width: { ideal: 1280 }, height: { ideal: 720 }, - facingMode: 'user', - }, + facingMode: 'user' + } }); + webcamDisponivel = true; + if (videoElement) { videoElement.srcObject = stream; await videoElement.play(); + + // Se for captura automática, aguardar um pouco e capturar + if (autoCapture) { + // Aguardar 1 segundo para o usuário se posicionar + setTimeout(() => { + if (videoElement && canvasElement && !capturando && !previewUrl) { + capturar(); + } + }, 1000); + } } } catch (error) { console.error('Erro ao acessar webcam:', error); - erro = 'Erro ao acessar webcam. Verifique as permissões.'; + erro = 'Erro ao acessar webcam. Continuando sem foto.'; webcamDisponivel = false; + // Se for captura automática e houver erro, chamar onError para continuar sem foto + if (autoCapture && onError) { + setTimeout(() => { + onError(); + }, 500); + } } }); @@ -56,6 +79,9 @@ async function capturar() { if (!videoElement || !canvasElement) { + if (autoCapture && onError) { + onError(); + } return; } @@ -71,12 +97,31 @@ stream.getTracks().forEach((track) => track.stop()); stream = null; } + + // Se for captura automática, confirmar automaticamente após um pequeno delay + if (autoCapture) { + setTimeout(() => { + confirmar(); + }, 500); + } } else { erro = 'Falha ao capturar imagem'; + // Se for captura automática e falhar, continuar sem foto + if (autoCapture && onError) { + setTimeout(() => { + onError(); + }, 500); + } } } catch (error) { console.error('Erro ao capturar:', error); - erro = 'Erro ao capturar imagem'; + erro = 'Erro ao capturar imagem. Continuando sem foto.'; + // Se for captura automática e houver erro, continuar sem foto + if (autoCapture && onError) { + setTimeout(() => { + onError(); + }, 500); + } } finally { capturando = false; } @@ -116,8 +161,8 @@ video: { width: { ideal: 1280 }, height: { ideal: 720 }, - facingMode: 'user', - }, + facingMode: 'user' + } }); if (videoElement) { @@ -131,69 +176,102 @@ } -
+
{#if !webcamDisponivel && !erro} -
+
Verificando webcam...
- {:else if erro && !webcamDisponivel} -
+ {#if !autoCapture} +
+ +
+ {/if} + {:else if erro && !webcamDisponivel} +
{erro}
- + {#if autoCapture} +
+ O registro será feito sem foto. +
+ {:else} +
+ +
+ {/if} {:else if previewUrl} -
- Preview -
- - - -
+
+ {#if autoCapture} + +
+ Foto capturada automaticamente... +
+ {/if} + Preview + {#if !autoCapture} + +
+ + + +
+ {/if}
{:else} -
-
+
+ {#if autoCapture} +
+ Capturando foto automaticamente... +
+ {/if} +
{#if erro} -
+
{erro}
{/if} -
- - -
+ {#if !autoCapture} + +
+ + +
+ {/if}
{/if}
- diff --git a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte index 183d11a..ce48be3 100644 --- a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte +++ b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte @@ -1,68 +1,68 @@ -
+
-
-
-
- -
-
-

Gestão de Pontos

-

Registros de ponto do dia

+ +
+
+
+
+
+

+ Gestão de Pontos +

+

Registros de ponto do dia

+
- {#if estatisticas} - diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index 5d939fc..c91d645 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -248,7 +248,7 @@ async function obterLocalizacao(): Promise<{ return new Promise((resolve) => { const timeout = setTimeout(() => { resolve({}); - }, 10000); // Timeout de 10 segundos + }, 5000); // Timeout de 5 segundos (reduzido para não bloquear) navigator.geolocation.getCurrentPosition( async (position) => { @@ -306,9 +306,9 @@ async function obterLocalizacao(): Promise<{ resolve({}); }, { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 0, + enableHighAccuracy: false, // false para ser mais rápido + timeout: 5000, // Timeout reduzido para 5 segundos + maximumAge: 60000, // Aceitar localização de até 1 minuto atrás } ); }); diff --git a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte index ce0a86b..f36c385 100644 --- a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte @@ -117,7 +117,7 @@
-
+
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 d88081a..6f97ff6 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle } from 'lucide-svelte'; + import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto'; import jsPDF from 'jspdf'; @@ -17,18 +17,22 @@ let funcionarioIdFiltro = $state | ''>(''); let carregando = $state(false); - // Queries - const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); - const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, { + // Parâmetros reativos para queries + const registrosParams = $derived({ funcionarioId: funcionarioIdFiltro || undefined, dataInicio, dataFim, }); - const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, { + const estatisticasParams = $derived({ dataInicio, dataFim, }); + // Queries + const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); + const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams); + const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams); + const funcionarios = $derived(funcionariosQuery?.data || []); const registros = $derived(registrosQuery?.data || []); const estatisticas = $derived(estatisticasQuery?.data); @@ -39,6 +43,7 @@ string, { funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null; + funcionarioId: Id<'funcionarios'>; registros: typeof registros; } > = {}; @@ -48,6 +53,7 @@ if (!agrupados[key]) { agrupados[key] = { funcionario: registro.funcionario, + funcionarioId: registro.funcionarioId, registros: [], }; } @@ -57,6 +63,19 @@ return Object.values(agrupados); }); + // Query para banco de horas de cada funcionário + const funcionariosComBancoHoras = $derived.by(() => { + 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`; + } + async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) { const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId); if (registrosFuncionario.length === 0) { @@ -297,7 +316,7 @@
-
+

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

@@ -307,9 +326,39 @@

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

Banco de Horas

+

+ {formatarSaldoHoras(saldoAcumulado)} +

+
+
+
+ {/if} + {/key} +
-
+
diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 8165a5d..78e0afe 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -1,5 +1,5 @@ import { v } from 'convex/values'; -import { mutation, query } from './_generated/server'; +import { internalMutation, mutation, query } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; @@ -52,6 +52,7 @@ interface InformacoesDispositivo { /** * Calcula se o registro está dentro do prazo baseado na configuração + * Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true) */ function calcularStatusPonto( hora: number, @@ -59,6 +60,11 @@ function calcularStatusPonto( horarioConfigurado: string, toleranciaMinutos: number ): boolean { + // Se tolerância for 0, desconsiderar atrasos (qualquer registro é válido) + if (toleranciaMinutos === 0) { + return true; + } + const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number); const totalMinutosRegistro = hora * 60 + minuto; const totalMinutosConfigurado = horaConfig * 60 + minutoConfig; @@ -141,6 +147,7 @@ export const registrarPonto = mutation({ ), timestamp: v.number(), sincronizadoComServidor: v.boolean(), + justificativa: v.optional(v.string()), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); @@ -225,6 +232,7 @@ export const registrarPonto = mutation({ sincronizadoComServidor: args.sincronizadoComServidor, toleranciaMinutos: config.toleranciaMinutos, dentroDoPrazo, + justificativa: args.justificativa, ipAddress: args.informacoesDispositivo?.ipAddress, ipPublico: args.informacoesDispositivo?.ipPublico, ipLocal: args.informacoesDispositivo?.ipLocal, @@ -257,6 +265,9 @@ export const registrarPonto = mutation({ criadoEm: Date.now(), }); + // Atualizar banco de horas após registrar + await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config); + return { registroId, tipo, dentroDoPrazo }; }, }); @@ -421,8 +432,15 @@ export const obterRegistro = query({ simbolo = await ctx.db.get(funcionario.simboloId); } + // Obter URL da imagem se existir + let imagemUrl = null; + if (registro.imagemId) { + imagemUrl = await ctx.storage.getUrl(registro.imagemId); + } + return { ...registro, + imagemUrl, funcionario: funcionario ? { nome: funcionario.nome, @@ -440,3 +458,228 @@ export const obterRegistro = query({ }, }); +/** + * Calcula carga horária diária esperada em minutos + */ +function calcularCargaHorariaDiaria(config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; +}): number { + 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); + + const minutosEntrada = horaEntrada * 60 + minutoEntrada; + const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco; + const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco; + const minutosSaida = horaSaida * 60 + minutoSaida; + + // Calcular horas trabalhadas: (saída almoço - entrada) + (saída - retorno almoço) + const horasManha = minutosSaidaAlmoco - minutosEntrada; + const horasTarde = minutosSaida - minutosRetornoAlmoco; + + return horasManha + horasTarde; +} + +/** + * Calcula horas trabalhadas do dia baseado nos registros + */ +function calcularHorasTrabalhadas(registros: Array<{ + tipo: string; + hora: number; + minuto: number; +}>): number { + // Ordenar registros por timestamp + const registrosOrdenados = [...registros].sort((a, b) => { + const minutosA = a.hora * 60 + a.minuto; + const minutosB = b.hora * 60 + b.minuto; + return minutosA - minutosB; + }); + + let horasTrabalhadas = 0; + + // Procurar entrada e saída + const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); + const saida = registrosOrdenados.find((r) => r.tipo === 'saida'); + + if (entrada && saida) { + const minutosEntrada = entrada.hora * 60 + entrada.minuto; + const minutosSaida = saida.hora * 60 + saida.minuto; + + // Procurar saída e retorno do almoço + const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco'); + const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco'); + + if (saidaAlmoco && retornoAlmoco) { + // Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço) + const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto; + const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto; + + const horasManha = minutosSaidaAlmoco - minutosEntrada; + const horasTarde = minutosSaida - minutosRetornoAlmoco; + horasTrabalhadas = horasManha + horasTarde; + } else { + // Sem intervalo de almoço registrado: saída - entrada + horasTrabalhadas = minutosSaida - minutosEntrada; + } + } + + return horasTrabalhadas; +} + +/** + * Atualiza ou cria registro de banco de horas para o dia + */ +async function atualizarBancoHoras( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string, + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + } +): Promise { + // Buscar todos os registros do dia + const registrosDoDia = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .collect(); + + // Calcular carga horária esperada + const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + + // Calcular horas trabalhadas + const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia); + + // Calcular saldo (positivo = horas extras, negativo = déficit) + const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + + // Buscar banco de horas existente + const bancoHorasExistente = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .first(); + + const registrosPontoIds = registrosDoDia.map((r) => r._id); + + if (bancoHorasExistente) { + // Atualizar existente + await ctx.db.patch(bancoHorasExistente._id, { + cargaHorariaDiaria, + horasTrabalhadas, + saldoMinutos, + registrosPontoIds, + calculadoEm: Date.now(), + }); + } else { + // Criar novo + await ctx.db.insert('bancoHoras', { + funcionarioId, + data, + cargaHorariaDiaria, + horasTrabalhadas, + saldoMinutos, + registrosPontoIds, + calculadoEm: Date.now(), + }); + } +} + +/** + * Obtém histórico e saldo do dia + */ +export const obterHistoricoESaldoDia = query({ + args: { + funcionarioId: v.id('funcionarios'), + data: v.string(), // YYYY-MM-DD + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario || !usuario.funcionarioId) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + // Buscar registros do dia + const registros = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('data', args.data) + ) + .order('asc') + .collect(); + + // Buscar configuração de ponto + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + return { + registros: [], + cargaHorariaDiaria: 0, + horasTrabalhadas: 0, + saldoMinutos: 0, + }; + } + + // Calcular valores + const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + const horasTrabalhadas = calcularHorasTrabalhadas(registros); + const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + + return { + registros, + cargaHorariaDiaria, + horasTrabalhadas, + saldoMinutos, + }; + }, +}); + +/** + * Obtém banco de horas acumulado do funcionário + */ +export const obterBancoHorasFuncionario = query({ + args: { + funcionarioId: v.id('funcionarios'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + // Buscar todos os registros de banco de horas do funcionário + const bancosHoras = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .order('desc') + .collect(); + + // Calcular saldo acumulado + const saldoAcumuladoMinutos = bancosHoras.reduce((acc, bh) => acc + bh.saldoMinutos, 0); + + return { + bancosHoras, + saldoAcumuladoMinutos, + totalDias: bancosHoras.length, + }; + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 472c3d7..4cc0a51 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1387,6 +1387,9 @@ export default defineSchema({ connectionType: v.optional(v.string()), memoryInfo: v.optional(v.string()), + // Justificativa opcional para o registro + justificativa: v.optional(v.string()), + criadoEm: v.number(), }) .index("by_funcionario_data", ["funcionarioId", "data"]) @@ -1416,5 +1419,19 @@ export default defineSchema({ atualizadoPor: v.id("usuarios"), atualizadoEm: v.number(), }) - .index("by_ativo", ["usarServidorExterno"]) + .index("by_ativo", ["usarServidorExterno"]), + + // Banco de Horas - Saldo diário de horas trabalhadas + bancoHoras: defineTable({ + funcionarioId: v.id("funcionarios"), + data: v.string(), // YYYY-MM-DD + cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) + horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) + saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) + registrosPontoIds: v.array(v.id("registrosPonto")), // IDs dos registros do dia + calculadoEm: v.number(), + }) + .index("by_funcionario_data", ["funcionarioId", "data"]) + .index("by_funcionario", ["funcionarioId"]) + .index("by_data", ["data"]), }); From 67d6b3ec729063200235df098fb9b3ce9574773b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 18 Nov 2025 16:20:38 -0300 Subject: [PATCH 3/8] feat: enhance webcam capture and geolocation functionality - Improved webcam capture process with multiple constraint strategies for better compatibility and error handling. - Added loading state management for video readiness, enhancing user experience during webcam access. - Refactored geolocation retrieval to implement multiple strategies, improving accuracy and reliability in obtaining user location. - Enhanced error handling for both webcam and geolocation features, providing clearer feedback to users. --- .../lib/components/ponto/WebcamCapture.svelte | 274 ++++++++++++++++-- apps/web/src/lib/utils/deviceInfo.ts | 179 ++++++++---- 2 files changed, 360 insertions(+), 93 deletions(-) diff --git a/apps/web/src/lib/components/ponto/WebcamCapture.svelte b/apps/web/src/lib/components/ponto/WebcamCapture.svelte index ff983ec..99e55e1 100644 --- a/apps/web/src/lib/components/ponto/WebcamCapture.svelte +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -19,11 +19,56 @@ let capturando = $state(false); let erro = $state(null); let previewUrl = $state(null); + let videoReady = $state(false); + + // Efeito para garantir que o vídeo seja exibido quando o stream for atribuído + $effect(() => { + if (stream && videoElement && !videoReady && videoElement.srcObject !== stream) { + // Apenas atribuir se ainda não foi atribuído + videoElement.srcObject = stream; + videoElement.play() + .then(() => { + if (videoElement && videoElement.readyState >= 2) { + videoReady = true; + } + }) + .catch((err) => { + console.warn('Erro ao reproduzir vídeo no effect:', err); + }); + } + }); onMount(async () => { - // Tentar obter permissão de webcam automaticamente + // Aguardar um pouco para garantir que os elementos estejam no DOM + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verificar suporte if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { - erro = 'Webcam não suportada'; + // Tentar método alternativo (navegadores antigos) + const getUserMedia = + navigator.getUserMedia || + (navigator as any).webkitGetUserMedia || + (navigator as any).mozGetUserMedia || + (navigator as any).msGetUserMedia; + + if (!getUserMedia) { + erro = 'Webcam não suportada'; + if (autoCapture && onError) { + onError(); + } + return; + } + } + + // Aguardar videoElement estar disponível + let tentativas = 0; + while (!videoElement && tentativas < 10) { + await new Promise(resolve => setTimeout(resolve, 100)); + tentativas++; + } + + if (!videoElement) { + erro = 'Elemento de vídeo não encontrado'; if (autoCapture && onError) { onError(); } @@ -31,33 +76,149 @@ } try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { - width: { ideal: 1280 }, - height: { ideal: 720 }, - facingMode: 'user' + // Tentar diferentes configurações de webcam + const constraints = [ + { + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + facingMode: 'user' + } + }, + { + video: { + width: { ideal: 640 }, + height: { ideal: 480 }, + facingMode: 'user' + } + }, + { + video: { + facingMode: 'user' + } + }, + { + video: true } - }); + ]; - webcamDisponivel = true; + let ultimoErro: Error | null = null; + + for (const constraint of constraints) { + try { + console.log('Tentando acessar webcam com constraint:', constraint); + stream = await navigator.mediaDevices.getUserMedia(constraint); + + // Verificar se o stream tem tracks de vídeo + if (stream.getVideoTracks().length === 0) { + stream.getTracks().forEach(track => track.stop()); + stream = null; + continue; + } - if (videoElement) { - videoElement.srcObject = stream; - await videoElement.play(); + console.log('Webcam acessada com sucesso'); + webcamDisponivel = true; - // Se for captura automática, aguardar um pouco e capturar - if (autoCapture) { - // Aguardar 1 segundo para o usuário se posicionar - setTimeout(() => { - if (videoElement && canvasElement && !capturando && !previewUrl) { - capturar(); - } - }, 1000); + // Atribuir stream ao elemento de vídeo (o $effect também fará isso, mas garantimos aqui) + if (videoElement) { + videoElement.srcObject = stream; + + // Aguardar o vídeo estar pronto + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timeout ao carregar vídeo')); + }, 10000); + + const onLoadedMetadata = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('error', onError); + videoReady = true; + resolve(); + }; + + const onPlaying = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('error', onError); + videoReady = true; + resolve(); + }; + + const onError = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('error', onError); + reject(new Error('Erro ao carregar vídeo')); + }; + + videoElement.addEventListener('loadedmetadata', onLoadedMetadata); + videoElement.addEventListener('playing', onPlaying); + videoElement.addEventListener('error', onError); + + // Tentar reproduzir + videoElement.play() + .then(() => { + console.log('Vídeo iniciado, readyState:', videoElement?.readyState); + // Se já tiver metadata, resolver imediatamente + if (videoElement && videoElement.readyState >= 2) { + onLoadedMetadata(); + } + }) + .catch((err) => { + console.warn('Erro ao reproduzir vídeo:', err); + // Continuar mesmo assim se já tiver metadata + if (videoElement && videoElement.readyState >= 2) { + onLoadedMetadata(); + } else { + onError(); + } + }); + }); + + console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight); + } + + // Se for captura automática, aguardar um pouco e capturar + if (autoCapture) { + // Aguardar 1.5 segundos para o vídeo estabilizar + setTimeout(() => { + if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) { + capturar(); + } + }, 1500); + } + + // Sucesso, sair do loop + return; + } catch (err) { + console.warn('Falha ao acessar webcam com constraint:', constraint, err); + ultimoErro = err instanceof Error ? err : new Error(String(err)); + if (stream) { + stream.getTracks().forEach(track => track.stop()); + stream = null; + } + continue; } } + + // Se chegou aqui, todas as tentativas falharam + throw ultimoErro || new Error('Não foi possível acessar a webcam'); } catch (error) { console.error('Erro ao acessar webcam:', error); - erro = 'Erro ao acessar webcam. Continuando sem foto.'; + const errorMessage = error instanceof Error ? error.message : String(error); + + if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) { + erro = 'Permissão de webcam negada. Continuando sem foto.'; + } else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) { + erro = 'Nenhuma webcam encontrada. Continuando sem foto.'; + } else { + erro = 'Erro ao acessar webcam. Continuando sem foto.'; + } + webcamDisponivel = false; // Se for captura automática e houver erro, chamar onError para continuar sem foto if (autoCapture && onError) { @@ -79,19 +240,69 @@ async function capturar() { if (!videoElement || !canvasElement) { + console.error('Elementos de vídeo ou canvas não disponíveis'); if (autoCapture && onError) { onError(); } return; } + // Verificar se o vídeo está pronto + if (videoElement.readyState < 2) { + console.warn('Vídeo ainda não está pronto, aguardando...'); + await new Promise((resolve) => { + const checkReady = () => { + if (videoElement && videoElement.readyState >= 2) { + resolve(); + } else { + setTimeout(checkReady, 100); + } + }; + checkReady(); + }); + } + capturando = true; erro = null; try { - const blob = await capturarWebcamComPreview(videoElement, canvasElement); - if (blob) { + // Verificar dimensões do vídeo + if (!videoElement.videoWidth || !videoElement.videoHeight) { + throw new Error('Dimensões do vídeo não disponíveis'); + } + + // Configurar canvas com as dimensões do vídeo + canvasElement.width = videoElement.videoWidth; + canvasElement.height = videoElement.videoHeight; + + // Obter contexto do canvas + const ctx = canvasElement.getContext('2d'); + if (!ctx) { + throw new Error('Não foi possível obter contexto do canvas'); + } + + // Desenhar frame atual do vídeo no canvas + ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height); + + // Converter para blob + const blob = await new Promise((resolve, reject) => { + canvasElement.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Falha ao converter canvas para blob')); + } + }, + 'image/jpeg', + 0.92 // Qualidade ligeiramente reduzida para melhor compatibilidade + ); + }); + + if (blob && blob.size > 0) { previewUrl = URL.createObjectURL(blob); + console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes'); + // Parar stream para mostrar preview if (stream) { stream.getTracks().forEach((track) => track.stop()); @@ -105,13 +316,7 @@ }, 500); } } else { - erro = 'Falha ao capturar imagem'; - // Se for captura automática e falhar, continuar sem foto - if (autoCapture && onError) { - setTimeout(() => { - onError(); - }, 500); - } + throw new Error('Blob vazio ou inválido'); } } catch (error) { console.error('Erro ao capturar:', error); @@ -246,9 +451,16 @@ bind:this={videoElement} autoplay playsinline - class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain" + muted + class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black" + style="min-width: 320px; min-height: 240px;" > + {#if !videoReady && webcamDisponivel} +
+ +
+ {/if}
{#if erro}
diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index c91d645..f0f2c0f 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -230,7 +230,7 @@ function obterInformacoesMemoria(): string { } /** - * Obtém localização via GPS + * Obtém localização via GPS com múltiplas tentativas */ async function obterLocalizacao(): Promise<{ latitude?: number; @@ -242,76 +242,131 @@ async function obterLocalizacao(): Promise<{ pais?: string; }> { if (typeof navigator === 'undefined' || !navigator.geolocation) { + console.warn('Geolocalização não suportada'); return {}; } - return new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve({}); - }, 5000); // Timeout de 5 segundos (reduzido para não bloquear) + // Tentar múltiplas estratégias + const estrategias = [ + // Estratégia 1: Alta precisão (mais lento, mas mais preciso) + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0 + }, + // Estratégia 2: Precisão média (balanceado) + { + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 30000 + }, + // Estratégia 3: Rápido (usa cache) + { + enableHighAccuracy: false, + timeout: 5000, + maximumAge: 60000 + } + ]; - navigator.geolocation.getCurrentPosition( - async (position) => { - clearTimeout(timeout); - const { latitude, longitude, accuracy } = position.coords; + for (const options of estrategias) { + try { + const resultado = await new Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + endereco?: string; + cidade?: string; + estado?: string; + pais?: string; + }>((resolve) => { + const timeout = setTimeout(() => { + resolve({}); + }, options.timeout + 1000); - // Tentar obter endereço via reverse geocoding - let endereco = ''; - let cidade = ''; - let estado = ''; - let pais = ''; + navigator.geolocation.getCurrentPosition( + async (position) => { + clearTimeout(timeout); + const { latitude, longitude, accuracy } = position.coords; - try { - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1` - ); - if (response.ok) { - const data = (await response.json()) as { - address?: { - road?: string; - house_number?: string; - city?: string; - town?: string; - state?: string; - country?: string; - }; - }; - if (data.address) { - const addr = data.address; - if (addr.road) { - endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; - } - cidade = addr.city || addr.town || ''; - estado = addr.state || ''; - pais = addr.country || ''; + // Validar coordenadas + if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) { + resolve({}); + return; } - } - } catch (error) { - console.warn('Erro ao obter endereço:', error); - } - resolve({ - latitude, - longitude, - precisao: accuracy, - endereco, - cidade, - estado, - pais, - }); - }, - (error) => { - clearTimeout(timeout); - console.warn('Erro ao obter localização:', error); - resolve({}); - }, - { - enableHighAccuracy: false, // false para ser mais rápido - timeout: 5000, // Timeout reduzido para 5 segundos - maximumAge: 60000, // Aceitar localização de até 1 minuto atrás + // Tentar obter endereço via reverse geocoding + let endereco = ''; + let cidade = ''; + let estado = ''; + let pais = ''; + + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, + { + headers: { + 'User-Agent': 'SGSE-App/1.0' + } + } + ); + if (response.ok) { + const data = (await response.json()) as { + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + state?: string; + country?: string; + }; + }; + if (data.address) { + const addr = data.address; + if (addr.road) { + endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; + } + cidade = addr.city || addr.town || ''; + estado = addr.state || ''; + pais = addr.country || ''; + } + } + } catch (error) { + console.warn('Erro ao obter endereço:', error); + } + + resolve({ + latitude, + longitude, + precisao: accuracy, + endereco, + cidade, + estado, + pais, + }); + }, + (error) => { + clearTimeout(timeout); + console.warn('Erro ao obter localização:', error.code, error.message); + resolve({}); + }, + options + ); + }); + + // Se obteve localização, retornar + if (resultado.latitude && resultado.longitude) { + console.log('Localização obtida com sucesso:', resultado); + return resultado; } - ); - }); + } catch (error) { + console.warn('Erro na estratégia de geolocalização:', error); + continue; + } + } + + // Se todas as estratégias falharam, retornar vazio + console.warn('Não foi possível obter localização após todas as tentativas'); + return {}; } /** From d16f76daebeeb7fdfcab28c997609b8192baa6e9 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 19 Nov 2025 04:54:03 -0300 Subject: [PATCH 4/8] feat: update point registration process with mandatory photo capture and location details - Removed location details from the point receipt, now displayed only in detailed reports. - Implemented mandatory photo capture during point registration, enhancing accountability. - Added confirmation modal for users to verify details before finalizing point registration. - Improved error handling for webcam access and photo capture, ensuring a smoother user experience. - Enhanced UI components for better feedback and interaction during the registration process. --- .../components/ponto/ComprovantePonto.svelte | 29 +- .../lib/components/ponto/RegistroPonto.svelte | 197 ++++++-- .../lib/components/ponto/WebcamCapture.svelte | 431 ++++++++++++------ .../registro-pontos/+page.svelte | 417 ++++++++++++++++- 4 files changed, 882 insertions(+), 192 deletions(-) diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index cecf612..10c9f1e 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -118,34 +118,7 @@ ); yPosition += 10; - // Informações de Localização (se disponível) - if (registro.latitude && registro.longitude) { - doc.setFont('helvetica', 'bold'); - doc.text('LOCALIZAÇÃO', 15, yPosition); - doc.setFont('helvetica', 'normal'); - yPosition += 8; - doc.setFontSize(10); - - if (registro.endereco) { - doc.text(`Endereço: ${registro.endereco}`, 15, yPosition); - yPosition += 6; - } - if (registro.cidade) { - doc.text(`Cidade: ${registro.cidade}`, 15, yPosition); - yPosition += 6; - } - if (registro.estado) { - doc.text(`Estado: ${registro.estado}`, 15, yPosition); - yPosition += 6; - } - doc.text(`Coordenadas: ${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`, 15, yPosition); - yPosition += 6; - if (registro.precisao) { - doc.text(`Precisão: ${registro.precisao.toFixed(0)} metros`, 15, yPosition); - yPosition += 6; - } - yPosition += 5; - } + // Detalhes de localização removidos do comprovante (serão exibidos apenas no relatório detalhado) // Informações do Dispositivo (resumido) if (registro.browser || registro.sistemaOperacional) { diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index df03efd..6c1000d 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -45,6 +45,8 @@ let mensagemErroModal = $state(''); let detalhesErroModal = $state(''); let justificativa = $state(''); + let mostrandoModalConfirmacao = $state(false); + let dataHoraAtual = $state<{ data: string; hora: string } | null>(null); const registrosHoje = $derived(registrosHojeQuery?.data || []); const config = $derived(configQuery?.data); @@ -89,9 +91,64 @@ } } + // Verificar permissões de localização e webcam + async function verificarPermissoes(): Promise<{ localizacao: boolean; webcam: boolean }> { + let localizacaoAutorizada = false; + let webcamAutorizada = false; + + // Verificar permissão de geolocalização + if (navigator.geolocation) { + try { + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Timeout')); + }, 5000); + + navigator.geolocation.getCurrentPosition( + () => { + clearTimeout(timeoutId); + localizacaoAutorizada = true; + resolve(); + }, + () => { + clearTimeout(timeoutId); + reject(new Error('Permissão de localização negada')); + }, + { timeout: 5000, maximumAge: 0, enableHighAccuracy: false } + ); + }); + } catch (error) { + console.warn('Permissão de localização não concedida:', error); + } + } + + // Verificar permissão de webcam + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + webcamAutorizada = true; + // Parar o stream imediatamente, apenas verificamos a permissão + stream.getTracks().forEach(track => track.stop()); + } catch (error) { + console.warn('Permissão de webcam não concedida:', error); + } + } + + return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada }; + } + async function registrarPonto() { if (registrando) return; + // Verificar permissões antes de registrar + const permissoes = await verificarPermissoes(); + if (!permissoes.localizacao || !permissoes.webcam) { + mensagemErroModal = 'Permissões necessárias'; + detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; + mostrarModalErro = true; + return; + } + registrando = true; erro = null; sucesso = null; @@ -106,16 +163,17 @@ const timestamp = await obterTempoServidor(client); const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor - // Upload da imagem se houver (não bloquear se falhar) + // Upload da imagem (obrigatória agora) let imagemId: Id<'_storage'> | undefined = undefined; if (imagemCapturada) { try { imagemId = await uploadImagem(imagemCapturada); } catch (error) { - console.warn('Erro ao fazer upload da imagem, continuando sem foto:', error); - // Continuar sem foto se o upload falhar - imagemId = undefined; + console.error('Erro ao fazer upload da imagem:', error); + throw new Error('Erro ao fazer upload da imagem. Tente novamente.'); } + } else { + throw new Error('É necessário capturar uma foto para registrar o ponto.'); } // Registrar ponto @@ -131,6 +189,7 @@ sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`; imagemCapturada = null; justificativa = ''; // Limpar justificativa após registro + mostrandoModalConfirmacao = false; // Mostrar comprovante após 1 segundo setTimeout(() => { @@ -162,50 +221,82 @@ } } - function handleWebcamCapture(blob: Blob | null) { + async function handleWebcamCapture(blob: Blob | null) { if (blob) { imagemCapturada = blob; } mostrandoWebcam = false; - // Se estava capturando automaticamente, registrar o ponto após capturar (com ou sem foto) - if (capturandoAutomaticamente) { + // Se capturou a foto, mostrar modal de confirmação + if (blob && capturandoAutomaticamente) { capturandoAutomaticamente = false; - // Pequeno delay para garantir que a imagem foi processada (se houver) - setTimeout(() => { - registrarPonto(); - }, 100); + // Obter data e hora sincronizada do servidor + try { + const timestamp = await obterTempoServidor(client); + const dataObj = new Date(timestamp); + const data = dataObj.toLocaleDateString('pt-BR'); + const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + dataHoraAtual = { data, hora }; + } catch (error) { + console.warn('Erro ao obter tempo do servidor, usando tempo local:', error); + atualizarDataHoraAtual(); + } + mostrandoModalConfirmacao = true; } } function handleWebcamCancel() { - const estavaCapturando = capturandoAutomaticamente; mostrandoWebcam = false; capturandoAutomaticamente = false; imagemCapturada = null; - // Se estava capturando automaticamente e cancelou, registrar sem foto - if (estavaCapturando) { - registrarPonto(); - } + mostrandoModalConfirmacao = false; } function handleWebcamError() { - // Em caso de erro na captura, registrar sem foto + // Em caso de erro na captura, fechar tudo mostrandoWebcam = false; capturandoAutomaticamente = false; imagemCapturada = null; - // Registrar ponto sem foto - registrarPonto(); + mostrandoModalConfirmacao = false; + mensagemErroModal = 'Erro ao capturar foto'; + detalhesErroModal = 'Não foi possível acessar a webcam. Verifique as permissões do navegador.'; + mostrarModalErro = true; + } + + function atualizarDataHoraAtual() { + const agora = new Date(); + const data = agora.toLocaleDateString('pt-BR'); + const hora = agora.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + dataHoraAtual = { data, hora }; } async function iniciarRegistroComFoto() { if (registrando || coletandoInfo) return; - // Abrir webcam automaticamente + // Verificar permissões antes de abrir webcam + const permissoes = await verificarPermissoes(); + if (!permissoes.localizacao || !permissoes.webcam) { + mensagemErroModal = 'Permissões necessárias'; + detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; + mostrarModalErro = true; + return; + } + + // Abrir webcam capturandoAutomaticamente = true; mostrandoWebcam = true; } + function confirmarRegistro() { + mostrandoModalConfirmacao = false; + registrarPonto(); + } + + function cancelarRegistro() { + mostrandoModalConfirmacao = false; + imagemCapturada = null; + } + function fecharComprovante() { mostrandoComprovante = false; registroId = null; @@ -503,20 +594,15 @@ > @@ -533,6 +619,61 @@
{/if} + + {#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual} + + {/if} + {#if mostrandoComprovante && registroId} diff --git a/apps/web/src/lib/components/ponto/WebcamCapture.svelte b/apps/web/src/lib/components/ponto/WebcamCapture.svelte index 99e55e1..1bb93a3 100644 --- a/apps/web/src/lib/components/ponto/WebcamCapture.svelte +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -8,9 +8,10 @@ onCancel: () => void; onError?: () => void; autoCapture?: boolean; + fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto } - let { onCapture, onCancel, onError, autoCapture = false }: Props = $props(); + let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props(); let videoElement: HTMLVideoElement | null = $state(null); let canvasElement: HTMLCanvasElement | null = $state(null); @@ -23,24 +24,35 @@ // Efeito para garantir que o vídeo seja exibido quando o stream for atribuído $effect(() => { - if (stream && videoElement && !videoReady && videoElement.srcObject !== stream) { - // Apenas atribuir se ainda não foi atribuído - videoElement.srcObject = stream; - videoElement.play() - .then(() => { - if (videoElement && videoElement.readyState >= 2) { - videoReady = true; - } - }) - .catch((err) => { - console.warn('Erro ao reproduzir vídeo no effect:', err); - }); + if (stream && videoElement) { + // Sempre atualizar srcObject quando o stream mudar + if (videoElement.srcObject !== stream) { + videoElement.srcObject = stream; + } + + // Tentar reproduzir se ainda não estiver pronto + if (!videoReady && videoElement.readyState < 2) { + videoElement.play() + .then(() => { + // Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo + setTimeout(() => { + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + videoReady = true; + } + }, 300); + }) + .catch((err) => { + console.warn('Erro ao reproduzir vídeo no effect:', err); + }); + } else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + videoReady = true; + } } }); onMount(async () => { - // Aguardar um pouco para garantir que os elementos estejam no DOM - await new Promise(resolve => setTimeout(resolve, 100)); + // Aguardar mais tempo para garantir que os elementos estejam no DOM + await new Promise(resolve => setTimeout(resolve, 300)); // Verificar suporte if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { @@ -60,21 +72,8 @@ } } - // Aguardar videoElement estar disponível - let tentativas = 0; - while (!videoElement && tentativas < 10) { - await new Promise(resolve => setTimeout(resolve, 100)); - tentativas++; - } - - if (!videoElement) { - erro = 'Elemento de vídeo não encontrado'; - if (autoCapture && onError) { - onError(); - } - return; - } - + // Primeiro, tentar acessar a webcam antes de verificar o elemento + // Isso garante que temos permissão antes de tentar renderizar o vídeo try { // Tentar diferentes configurações de webcam const constraints = [ @@ -103,123 +102,193 @@ ]; let ultimoErro: Error | null = null; + let streamObtido = false; for (const constraint of constraints) { try { console.log('Tentando acessar webcam com constraint:', constraint); - stream = await navigator.mediaDevices.getUserMedia(constraint); + const tempStream = await navigator.mediaDevices.getUserMedia(constraint); // Verificar se o stream tem tracks de vídeo - if (stream.getVideoTracks().length === 0) { - stream.getTracks().forEach(track => track.stop()); - stream = null; + if (tempStream.getVideoTracks().length === 0) { + tempStream.getTracks().forEach(track => track.stop()); continue; } console.log('Webcam acessada com sucesso'); + stream = tempStream; webcamDisponivel = true; - - // Atribuir stream ao elemento de vídeo (o $effect também fará isso, mas garantimos aqui) - if (videoElement) { - videoElement.srcObject = stream; - - // Aguardar o vídeo estar pronto - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout ao carregar vídeo')); - }, 10000); - - const onLoadedMetadata = () => { - clearTimeout(timeout); - videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); - videoElement?.removeEventListener('playing', onPlaying); - videoElement?.removeEventListener('error', onError); - videoReady = true; - resolve(); - }; - - const onPlaying = () => { - clearTimeout(timeout); - videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); - videoElement?.removeEventListener('playing', onPlaying); - videoElement?.removeEventListener('error', onError); - videoReady = true; - resolve(); - }; - - const onError = () => { - clearTimeout(timeout); - videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); - videoElement?.removeEventListener('playing', onPlaying); - videoElement?.removeEventListener('error', onError); - reject(new Error('Erro ao carregar vídeo')); - }; - - videoElement.addEventListener('loadedmetadata', onLoadedMetadata); - videoElement.addEventListener('playing', onPlaying); - videoElement.addEventListener('error', onError); - - // Tentar reproduzir - videoElement.play() - .then(() => { - console.log('Vídeo iniciado, readyState:', videoElement?.readyState); - // Se já tiver metadata, resolver imediatamente - if (videoElement && videoElement.readyState >= 2) { - onLoadedMetadata(); - } - }) - .catch((err) => { - console.warn('Erro ao reproduzir vídeo:', err); - // Continuar mesmo assim se já tiver metadata - if (videoElement && videoElement.readyState >= 2) { - onLoadedMetadata(); - } else { - onError(); - } - }); - }); - - console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight); - } - - // Se for captura automática, aguardar um pouco e capturar - if (autoCapture) { - // Aguardar 1.5 segundos para o vídeo estabilizar - setTimeout(() => { - if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) { - capturar(); - } - }, 1500); - } - - // Sucesso, sair do loop - return; + streamObtido = true; + break; } catch (err) { console.warn('Falha ao acessar webcam com constraint:', constraint, err); ultimoErro = err instanceof Error ? err : new Error(String(err)); - if (stream) { - stream.getTracks().forEach(track => track.stop()); - stream = null; - } continue; } } - // Se chegou aqui, todas as tentativas falharam - throw ultimoErro || new Error('Não foi possível acessar a webcam'); + if (!streamObtido) { + throw ultimoErro || new Error('Não foi possível acessar a webcam'); + } + + // Agora que temos o stream, aguardar o elemento de vídeo estar disponível + let tentativas = 0; + while (!videoElement && tentativas < 30) { + await new Promise(resolve => setTimeout(resolve, 100)); + tentativas++; + } + + if (!videoElement) { + erro = 'Elemento de vídeo não encontrado'; + if (stream) { + stream.getTracks().forEach(track => track.stop()); + stream = null; + } + webcamDisponivel = false; + if (fotoObrigatoria) { + return; + } + if (autoCapture && onError) { + onError(); + } + return; + } + + // Atribuir stream ao elemento de vídeo + if (videoElement && stream) { + videoElement.srcObject = stream; + + // Aguardar o vídeo estar pronto com timeout maior + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + // Se o vídeo tem dimensões, considerar pronto mesmo sem eventos + if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + videoReady = true; + resolve(); + } else { + reject(new Error('Timeout ao carregar vídeo')); + } + }, 15000); // Aumentar timeout para 15 segundos + + const onLoadedMetadata = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + // Aguardar um pouco mais para garantir que o vídeo esteja realmente visível + setTimeout(() => { + videoReady = true; + resolve(); + }, 200); + }; + + const onLoadedData = () => { + if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + videoReady = true; + resolve(); + } + }; + + const onPlaying = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + videoReady = true; + resolve(); + }; + + const onError = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + reject(new Error('Erro ao carregar vídeo')); + }; + + videoElement.addEventListener('loadedmetadata', onLoadedMetadata); + videoElement.addEventListener('loadeddata', onLoadedData); + videoElement.addEventListener('playing', onPlaying); + videoElement.addEventListener('error', onError); + + // Tentar reproduzir + videoElement.play() + .then(() => { + console.log('Vídeo iniciado, readyState:', videoElement?.readyState); + // Se já tiver metadata e dimensões, resolver imediatamente + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + setTimeout(() => { + onLoadedMetadata(); + }, 300); + } + }) + .catch((err) => { + console.warn('Erro ao reproduzir vídeo:', err); + // Continuar mesmo assim se já tiver metadata e dimensões + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + setTimeout(() => { + onLoadedMetadata(); + }, 300); + } else { + // Aguardar um pouco mais antes de dar erro + setTimeout(() => { + if (videoElement && videoElement.videoWidth > 0) { + onLoadedMetadata(); + } else { + onError(); + } + }, 1000); + } + }); + }); + + console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight); + } + + // Se for captura automática, aguardar um pouco e capturar + if (autoCapture) { + // Aguardar 1.5 segundos para o vídeo estabilizar + setTimeout(() => { + if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) { + capturar(); + } + }, 1500); + } + + // Sucesso, sair do try + return; } catch (error) { console.error('Erro ao acessar webcam:', error); const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) { - erro = 'Permissão de webcam negada. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.' + : 'Permissão de webcam negada. Continuando sem foto.'; } else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) { - erro = 'Nenhuma webcam encontrada. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.' + : 'Nenhuma webcam encontrada. Continuando sem foto.'; } else { - erro = 'Erro ao acessar webcam. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.' + : 'Erro ao acessar webcam. Continuando sem foto.'; } webcamDisponivel = false; + // Se foto é obrigatória, não chamar onError para permitir continuar sem foto + if (fotoObrigatoria) { + // Apenas mostrar o erro e aguardar o usuário fechar ou tentar novamente + return; + } // Se for captura automática e houver erro, chamar onError para continuar sem foto if (autoCapture && onError) { setTimeout(() => { @@ -247,28 +316,40 @@ return; } - // Verificar se o vídeo está pronto - if (videoElement.readyState < 2) { + // Verificar se o vídeo está pronto e tem dimensões válidas + if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) { console.warn('Vídeo ainda não está pronto, aguardando...'); - await new Promise((resolve) => { + await new Promise((resolve, reject) => { + let tentativas = 0; + const maxTentativas = 50; // 5 segundos const checkReady = () => { - if (videoElement && videoElement.readyState >= 2) { + tentativas++; + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { resolve(); + } else if (tentativas >= maxTentativas) { + reject(new Error('Timeout aguardando vídeo ficar pronto')); } else { setTimeout(checkReady, 100); } }; checkReady(); + }).catch((error) => { + console.error('Erro ao aguardar vídeo:', error); + erro = 'Vídeo não está pronto. Aguarde um momento e tente novamente.'; + capturando = false; + return; // Retornar aqui para não continuar }); + + // Se chegou aqui, o vídeo está pronto, continuar com a captura } capturando = true; erro = null; try { - // Verificar dimensões do vídeo + // Verificar dimensões do vídeo novamente antes de capturar if (!videoElement.videoWidth || !videoElement.videoHeight) { - throw new Error('Dimensões do vídeo não disponíveis'); + throw new Error('Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.'); } // Configurar canvas com as dimensões do vídeo @@ -281,7 +362,11 @@ throw new Error('Não foi possível obter contexto do canvas'); } + // Limpar canvas antes de desenhar + ctx.clearRect(0, 0, canvasElement.width, canvasElement.height); + // Desenhar frame atual do vídeo no canvas + // O vídeo está espelhado no CSS para visualização, mas capturamos normalmente ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height); // Converter para blob @@ -320,7 +405,15 @@ } } catch (error) { console.error('Erro ao capturar:', error); - erro = 'Erro ao capturar imagem. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Erro ao capturar imagem. Tente novamente.' + : 'Erro ao capturar imagem. Continuando sem foto.'; + // Se foto é obrigatória, não chamar onError para permitir continuar sem foto + if (fotoObrigatoria) { + // Apenas mostrar o erro e permitir que o usuário tente novamente + capturando = false; + return; + } // Se for captura automática e houver erro, continuar sem foto if (autoCapture && onError) { setTimeout(() => { @@ -387,17 +480,68 @@ Verificando webcam...
- {#if !autoCapture} + {#if !autoCapture && !fotoObrigatoria}
+ {:else if fotoObrigatoria} +
+ + A captura de foto é obrigatória para registrar o ponto. +
{/if} {:else if erro && !webcamDisponivel} -
+
{erro}
- {#if autoCapture} + {#if fotoObrigatoria} +
+ Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente. +
+
+ + +
+ {:else if autoCapture}
O registro será feito sem foto.
@@ -445,6 +589,10 @@
Capturando foto automaticamente...
+ {:else} +
+ Posicione-se na frente da câmera e clique em "Capturar Foto" +
{/if}
{#if !videoReady && webcamDisponivel} -
+
+ Carregando câmera... +
+ {:else if videoReady && webcamDisponivel} +
+
+ + Câmera ativa +
{/if}
@@ -468,15 +624,20 @@
{/if} {#if !autoCapture} - +
- + {/each} From b660d123d4710c05664c94f06da3c81c168a3fc0 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 19 Nov 2025 05:09:54 -0300 Subject: [PATCH 5/8] feat: add PDF receipt generation for point registration - Implemented a new feature to generate a PDF receipt for point registrations, including employee and registration details. - Integrated jsPDF for PDF creation and added functionality to include a logo and captured images in the receipt. - Enhanced the UI with a print button for users to easily access the receipt generation feature. - Improved the confirmation modal layout for better user experience during point registration. --- .../lib/components/ponto/RegistroPonto.svelte | 398 ++++++++++++++++-- 1 file changed, 358 insertions(+), 40 deletions(-) diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 6c1000d..ad0f920 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -13,8 +13,11 @@ getTipoRegistroLabel, getProximoTipoRegistro } from '$lib/utils/ponto'; - import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte'; + import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; + import jsPDF from 'jspdf'; + import logoGovPE from '$lib/assets/logo_governo_PE.png'; + import { formatarDataHoraCompleta } from '$lib/utils/ponto'; const client = useConvexClient(); @@ -309,6 +312,206 @@ erro = null; } + async function imprimirComprovante(registroId: Id<'registrosPonto'>) { + try { + // Buscar dados completos do registro + const registro = await client.query(api.pontos.obterRegistro, { registroId }); + + if (!registro) { + alert('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 = 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); + } + + // Cabeçalho + doc.setFontSize(16); + doc.setTextColor(41, 128, 185); + doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' }); + + yPosition += 15; + + // Informações do Funcionário + doc.setFontSize(12); + doc.setTextColor(0, 0, 0); + doc.setFont('helvetica', 'bold'); + doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition); + doc.setFont('helvetica', 'normal'); + + yPosition += 8; + doc.setFontSize(10); + + if (registro.funcionario) { + if (registro.funcionario.matricula) { + doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition); + yPosition += 6; + } + doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition); + yPosition += 6; + if (registro.funcionario.descricaoCargo) { + doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition); + yPosition += 6; + } + } + + yPosition += 5; + + // Informações do Registro + doc.setFont('helvetica', 'bold'); + doc.text('DADOS DO REGISTRO', 15, yPosition); + doc.setFont('helvetica', 'normal'); + + yPosition += 8; + doc.setFontSize(10); + + doc.text(`Tipo: ${getTipoRegistroLabel(registro.tipo)}`, 15, yPosition); + yPosition += 6; + + const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo); + doc.text(`Data e Hora: ${dataHora}`, 15, yPosition); + yPosition += 6; + + doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition); + yPosition += 6; + + doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition); + yPosition += 6; + + doc.text( + `Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`, + 15, + yPosition + ); + yPosition += 10; + + // Imagem capturada (se disponível) + if (registro.imagemUrl) { + // Verificar se precisa de nova página + 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 { + // Carregar imagem usando fetch para evitar problemas de CORS + const response = await fetch(registro.imagemUrl); + if (!response.ok) { + throw new Error('Erro ao carregar imagem'); + } + + const blob = await response.blob(); + const reader = new FileReader(); + + // Converter blob para base64 + const base64 = await new Promise((resolve, reject) => { + reader.onloadend = () => { + 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); + }); + + // Criar elemento de imagem para obter dimensões + 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); + }); + + // Calcular dimensões para caber na página (largura máxima 80mm, manter proporção) + 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) { + // Imagem horizontal + imgWidth = maxWidth; + imgHeight = maxWidth / aspectRatio; + } else { + // Imagem vertical + imgHeight = maxHeight; + imgWidth = maxHeight * aspectRatio; + } + } + + // Centralizar imagem + const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2; + + // Verificar se cabe na página atual + if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPosition = 20; + } + + // Adicionar imagem ao PDF usando base64 + doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight); + 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' }); + yPosition += 6; + } + } + + // 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' } + ); + } + + // Salvar + const nomeArquivo = `comprovante-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 comprovante PDF:', error); + alert('Erro ao gerar comprovante PDF. Tente novamente.'); + } + } + const podeRegistrar = $derived.by(() => { return !registrando && !coletandoInfo && config !== undefined; }); @@ -555,7 +758,7 @@ {#each registrosOrdenados as registro (registro._id)}
-
+
{getTipoRegistroLabel(registro.tipo)} @@ -575,6 +778,16 @@
{/if}
+
+ +
@@ -621,53 +834,158 @@ {#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual} -