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..aabb54f --- /dev/null +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -0,0 +1,344 @@ + + + + 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..801507b --- /dev/null +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -0,0 +1,1046 @@ + + +
+ +
+
+ +
+
+ + +
+
+

+ + Horários do Dia +

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

Registrar Ponto

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

Próximo registro: {tipoLabel}

+
+ + +
+ + +
+ + +
+
+
+ + + {#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)} +
+
+
+
+
+ + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(registro.tipo)} + + {#if registro.dentroDoPrazo} + + {:else} + + {/if} +
+

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

+ {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} +
+
+ +
+
+
+
+ {/each} +
+
+
+
+ {/if} + + + {#if mostrandoWebcam} + + {/if} + + + {#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual} + + {/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..1bb93a3 --- /dev/null +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -0,0 +1,650 @@ + + +
+ {#if !webcamDisponivel && !erro} +
+ + Verificando webcam... +
+ {#if !autoCapture && !fotoObrigatoria} +
+ +
+ {:else if fotoObrigatoria} +
+ + A captura de foto é obrigatória para registrar o ponto. +
+ {/if} + {:else if erro && !webcamDisponivel} +
+ + {erro} +
+ {#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. +
+ {:else} +
+ +
+ {/if} + {:else if previewUrl} + +
+ {#if autoCapture} + +
+ Foto capturada automaticamente... +
+ {/if} + Preview + {#if !autoCapture} + +
+ + + +
+ {/if} +
+ {:else} + +
+ {#if autoCapture} +
+ 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} +
+ {#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 new file mode 100644 index 0000000..ce48be3 --- /dev/null +++ b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte @@ -0,0 +1,68 @@ + + +
+
+ +
+
+
+ +
+
+
+

+ Gestão de Pontos +

+

Registros de ponto do dia

+
+
+ + + +
+
+ diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts new file mode 100644 index 0000000..f0f2c0f --- /dev/null +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -0,0 +1,452 @@ +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 com múltiplas tentativas + */ +async function obterLocalizacao(): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + endereco?: string; + cidade?: string; + estado?: string; + pais?: string; +}> { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + console.warn('Geolocalização não suportada'); + return {}; + } + + // 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 + } + ]; + + 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); + + navigator.geolocation.getCurrentPosition( + async (position) => { + clearTimeout(timeout); + const { latitude, longitude, accuracy } = position.coords; + + // Validar coordenadas + if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) { + resolve({}); + return; + } + + // 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 {}; +} + +/** + * 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..0015069 --- /dev/null +++ b/apps/web/src/lib/utils/ponto.ts @@ -0,0 +1,124 @@ +/** + * 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 + * Se config fornecida, usa os nomes personalizados, senão usa os padrões + */ +export function getTipoRegistroLabel( + tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', + config?: { + nomeEntrada?: string; + nomeSaidaAlmoco?: string; + nomeRetornoAlmoco?: string; + nomeSaida?: string; + } +): string { + // Se config fornecida, usar nomes personalizados + if (config) { + const labels: Record = { + entrada: config.nomeEntrada || 'Entrada 1', + saida_almoco: config.nomeSaidaAlmoco || 'Saída 1', + retorno_almoco: config.nomeRetornoAlmoco || 'Entrada 2', + saida: config.nomeSaida || 'Saída 2', + }; + return labels[tipo] || tipo; + } + + // Valores padrão + const labels: Record = { + entrada: 'Entrada 1', + saida_almoco: 'Saída 1', + retorno_almoco: 'Entrada 2', + saida: 'Saída 2', + }; + 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..f36c385 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..e2df0d1 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -0,0 +1,935 @@ + + +
+ +
+
+
+ +
+
+

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} +
+ + + {#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} + + +
+ +
+ + + + + + + + + + + + {#each grupo.registros as registro} + + + + + + + + {/each} + +
DataTipoHorárioStatusAções
{registro.data} + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeEntrada: config.nomeEntrada, + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeRetornoAlmoco: config.nomeRetornoAlmoco, + nomeSaida: config.nomeSaida, + }) + : 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..5edcb61 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 +
+
+ +
+ + +

Nomes dos Registros

+

+ Personalize os nomes exibidos para cada tipo de registro de ponto +

+ +
+ +
+ + +
+ Nome exibido para o primeiro registro do dia +
+
+ + +
+ + +
+ Nome exibido para a saída para almoço +
+
+ + +
+ + +
+ Nome exibido para o retorno do almoço +
+
+ + +
+ + +
+ Nome exibido para a saída final do dia +
+
+
+ + +
+ +
+
+
+
+ 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..2e4dcec --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte @@ -0,0 +1,282 @@ + + +
+ +
+
+
+ +
+
+

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

Ajuste de Fuso Horário (GMT)

+

+ Configure o fuso horário para ajustar o horário de registro. Use valores negativos para fusos a oeste de UTC e positivos para fusos a leste. +

+
+ + +
+ Ajuste em horas em relação ao UTC. Exemplo: -3 para horário de Brasília (GMT-3) +
+
+ + +
+ {#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 4900e82..fdf3457 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -37,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"; @@ -73,6 +74,8 @@ declare const fullApi: ApiFromModules<{ chat: typeof chat; configuracaoEmail: typeof configuracaoEmail; contratos: typeof contratos; + configuracaoPonto: typeof configuracaoPonto; + configuracaoRelogio: typeof configuracaoRelogio; crons: typeof crons; cursos: typeof cursos; dashboard: typeof dashboard; @@ -88,6 +91,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..a495a09 --- /dev/null +++ b/packages/backend/convex/configuracaoPonto.ts @@ -0,0 +1,145 @@ +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, + nomeEntrada: 'Entrada 1', + nomeSaidaAlmoco: 'Saída 1', + nomeRetornoAlmoco: 'Entrada 2', + nomeSaida: 'Saída 2', + ativo: false, + }; + } + + // Garantir que os nomes padrão estejam definidos + return { + ...config, + nomeEntrada: config.nomeEntrada || 'Entrada 1', + nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1', + nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2', + nomeSaida: config.nomeSaida || 'Saída 2', + }; + }, +}); + +/** + * 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(), + nomeEntrada: v.optional(v.string()), + nomeSaidaAlmoco: v.optional(v.string()), + nomeRetornoAlmoco: v.optional(v.string()), + nomeSaida: v.optional(v.string()), + }, + 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, + nomeEntrada: args.nomeEntrada || 'Entrada 1', + nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1', + nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2', + nomeSaida: args.nomeSaida || 'Saída 2', + 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..b932127 --- /dev/null +++ b/packages/backend/convex/configuracaoRelogio.ts @@ -0,0 +1,209 @@ +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, + gmtOffset: 0, + }; + } + + return { + ...config, + gmtOffset: config.gmtOffset ?? 0, + }; + }, +}); + +/** + * 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(), + gmtOffset: v.optional(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 + + // 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, + gmtOffset: args.gmtOffset ?? 0, + 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, + gmtOffset: args.gmtOffset ?? 0, + 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..148ac73 --- /dev/null +++ b/packages/backend/convex/pontos.ts @@ -0,0 +1,660 @@ +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(); + }, +}); + +/** + * 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, + minuto: number, + 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; + 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(), + justificativa: v.optional(v.string()), + }, + 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'); + } + + // Obter configuração de ponto para GMT offset (buscar configuração ativa) + const configPonto = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + // Converter timestamp para data/hora com ajuste de GMT + const gmtOffset = configPonto?.gmtOffset ?? 0; + const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000); + const dataObj = new Date(timestampAjustado); + const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD + const hora = dataObj.getUTCHours(); + const minuto = dataObj.getUTCMinutes(); + const segundo = dataObj.getUTCSeconds(); + + // 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, + justificativa: args.justificativa, + 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(), + }); + + // Atualizar banco de horas após registrar + await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config); + + 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 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); + } + + // 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, + matricula: funcionario.matricula, + descricaoCargo: funcionario.descricaoCargo, + simbolo: simbolo + ? { + nome: simbolo.nome, + tipo: simbolo.tipo, + } + : null, + } + : null, + }; + }, +}); + +/** + * 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 4d22d33..9b6a3d4 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1392,5 +1392,122 @@ 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()), + + // Justificativa opcional para o registro + justificativa: 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(), + // Nomes personalizados dos tipos de registro + nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1" + nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1" + nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2" + nomeSaida: v.optional(v.string()), // Padrão: "Saída 2" + // Ajuste de fuso horário (GMT offset em horas) + gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + ativo: v.boolean(), + atualizadoPor: v.id("usuarios"), + atualizadoEm: v.number(), + }) + .index("by_ativo", ["ativo"]), + + configuracaoRelogio: defineTable({ + servidorNTP: v.optional(v.string()), + portaNTP: v.optional(v.number()), + usarServidorExterno: v.boolean(), + fallbackParaPC: v.boolean(), + ultimaSincronizacao: v.optional(v.number()), + offsetSegundos: v.optional(v.number()), + // Ajuste de fuso horário (GMT offset em horas) + gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC) + atualizadoPor: v.id("usuarios"), + atualizadoEm: v.number(), + }) + .index("by_ativo", ["usarServidorExterno"]), + + // Banco de Horas - Saldo diário de horas trabalhadas + bancoHoras: defineTable({ + funcionarioId: v.id("funcionarios"), + data: v.string(), // YYYY-MM-DD + cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) + horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) + saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) + registrosPontoIds: v.array(v.id("registrosPonto")), // IDs dos registros do dia + calculadoEm: v.number(), + }) + .index("by_funcionario_data", ["funcionarioId", "data"]) + .index("by_funcionario", ["funcionarioId"]) + .index("by_data", ["data"]), });