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'; import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao'; /** * Calcula distância entre duas coordenadas (fórmula de Haversine) * Retorna distância em metros */ function calcularDistancia(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; // Raio da Terra em metros const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } /** * Obtém geolocalização aproximada por IP usando serviço externo */ async function obterGeoPorIP(ipAddress: string): Promise<{ latitude: number; longitude: number; cidade?: string; estado?: string; pais?: string; } | null> { try { // Usar ipapi.co (gratuito, sem chave para uso limitado) const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, { headers: { 'User-Agent': 'SGSE-App/1.0' } }); if (response.ok) { const data = (await response.json()) as { latitude?: number; longitude?: number; city?: string; region?: string; country_name?: string; error?: boolean; }; if (!data.error && data.latitude && data.longitude) { return { latitude: data.latitude, longitude: data.longitude, cidade: data.city, estado: data.region, pais: data.country_name }; } } } catch (error) { console.warn('Erro ao obter geolocalização por IP:', error); } return null; } /** * Valida localização contra IP geolocation e histórico * Retorna informações detalhadas para salvar no registro */ async function validarLocalizacao( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, latitude: number, longitude: number, ipAddress?: string, confiabilidadeGPS?: number ): Promise<{ valida: boolean; motivo?: string; scoreConfianca: number; // 0-1 avisos: string[]; distanciaIPvsGPS?: number; // Distância em metros entre IP geolocation e GPS velocidadeUltimoRegistro?: number; // Velocidade calculada em km/h distanciaUltimoRegistro?: number; // Distância em metros do último registro tempoDecorridoHoras?: number; // Tempo em horas desde último registro }> { const avisos: string[] = []; let scoreConfianca = confiabilidadeGPS || 0.5; let valida = true; let distanciaIPvsGPS: number | undefined = undefined; let velocidadeUltimoRegistro: number | undefined = undefined; let distanciaUltimoRegistro: number | undefined = undefined; let tempoDecorridoHoras: number | undefined = undefined; // 1. Validar coordenadas básicas if ( isNaN(latitude) || isNaN(longitude) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 ) { return { valida: false, motivo: 'Coordenadas inválidas', scoreConfianca: 0, avisos: [], distanciaIPvsGPS, velocidadeUltimoRegistro, distanciaUltimoRegistro, tempoDecorridoHoras }; } // 2. Comparar com geolocalização do IP if (ipAddress) { const ipGeo = await obterGeoPorIP(ipAddress); if (ipGeo) { distanciaIPvsGPS = calcularDistancia(latitude, longitude, ipGeo.latitude, ipGeo.longitude); // Se diferença > 50km, muito suspeito if (distanciaIPvsGPS > 50000) { valida = false; scoreConfianca = Math.min(scoreConfianca, 0.2); avisos.push( `Localização GPS (${latitude.toFixed(6)}, ${longitude.toFixed(6)}) está muito distante da localização do IP (${distanciaIPvsGPS.toFixed(0)}m). Possível falsificação.` ); } else if (distanciaIPvsGPS > 10000) { // Se diferença entre 10-50km, suspeito mas aceitável (pode ser VPN/mobile) scoreConfianca *= 0.7; avisos.push( `Localização GPS está a ${distanciaIPvsGPS.toFixed(0)}m da localização do IP. Isso pode ser normal se estiver usando VPN ou dados móveis.` ); } else if (distanciaIPvsGPS < 5000) { // Se diferença < 5km, aumenta confiança scoreConfianca = Math.min(scoreConfianca + 0.2, 1); } } } // 3. Validar histórico de localizações do funcionário const ultimosRegistros = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId)) .order('desc') .take(5); if (ultimosRegistros.length > 0) { // Verificar movimento impossível for (const registro of ultimosRegistros) { if (registro.latitude && registro.longitude && registro.timestamp) { distanciaUltimoRegistro = calcularDistancia( latitude, longitude, registro.latitude, registro.longitude ); const tempoDecorrido = Date.now() - registro.timestamp; tempoDecorridoHoras = tempoDecorrido / (1000 * 60 * 60); // Calcular velocidade (km/h) se tempo decorrido > 0 if (tempoDecorridoHoras > 0 && tempoDecorridoHoras < 24) { velocidadeUltimoRegistro = distanciaUltimoRegistro / 1000 / tempoDecorridoHoras; // km/h // Se velocidade > 1000 km/h, impossível (mais rápido que avião) if (velocidadeUltimoRegistro > 1000) { valida = false; scoreConfianca = 0; avisos.push( `Movimento impossível detectado: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Localização anterior há ${tempoDecorridoHoras.toFixed(1)}h está a ${(distanciaUltimoRegistro / 1000).toFixed(1)}km.` ); break; } // Se velocidade > 200 km/h, suspeito (mas possível em avião) if (velocidadeUltimoRegistro > 200 && velocidadeUltimoRegistro <= 1000) { scoreConfianca *= 0.6; avisos.push( `Movimento muito rápido: ${velocidadeUltimoRegistro.toFixed(0)} km/h. Pode ser viagem, mas verifique se é legítimo.` ); } } break; // Usar apenas o último registro } } } // 4. Validar confiabilidade GPS do frontend if (confiabilidadeGPS !== undefined) { if (confiabilidadeGPS < 0.3) { scoreConfianca *= 0.5; avisos.push( `Confiabilidade GPS baixa (${(confiabilidadeGPS * 100).toFixed(0)}%). Localização pode não ser precisa.` ); } } return { valida, motivo: avisos.length > 0 ? avisos[0] : undefined, scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)), avisos, distanciaIPvsGPS, velocidadeUltimoRegistro, distanciaUltimoRegistro, tempoDecorridoHoras }; } /** * Valida dados de acelerômetro para detectar autenticidade do registro * Retorna informações de validação sem bloquear o registro */ function validarAcelerometro( isDesktop: boolean | undefined, sensorDisponivel: boolean | undefined, permissaoSensorNegada: boolean | undefined, acelerometroX: number | undefined, acelerometroY: number | undefined, acelerometroZ: number | undefined, movimentoDetectado: boolean | undefined, magnitudeMovimento: number | undefined, variacaoAcelerometro: number | undefined ): { valida: boolean; motivo?: string; scoreConfianca: number; // 0-1 avisos: string[]; } { const avisos: string[] = []; let scoreConfianca = 1.0; // Se for desktop, ausência de sensor não é suspeito if (isDesktop === true) { if (sensorDisponivel === false || !acelerometroX) { // Desktop não tem sensor - isso é normal, não reduzir confiança return { valida: true, scoreConfianca: 1.0, avisos: [] }; } } // Se permissão foi negada, apenas reduzir score de confiança (não bloqueia registro) if (permissaoSensorNegada === true) { scoreConfianca *= 0.9; avisos.push('Permissão de sensor negada pelo usuário (não bloqueia registro)'); // Continuar validação normalmente } // Se sensor não está disponível e não é desktop, pode ser suspeito (mas não bloqueia) if (sensorDisponivel === false && isDesktop !== true) { scoreConfianca *= 0.8; avisos.push('Sensor de movimento não disponível no dispositivo móvel'); } // Se sensor está disponível mas não há dados, pode ser suspeito if (sensorDisponivel === true && (!acelerometroX || !acelerometroY || !acelerometroZ)) { scoreConfianca *= 0.7; avisos.push('Sensor disponível mas dados de acelerômetro não coletados'); } // Se há dados de acelerômetro, validar if (acelerometroX !== undefined && acelerometroY !== undefined && acelerometroZ !== undefined) { // Verificar se valores são realistas (aceleração geralmente entre -20 e +20 m/s² em uso normal) const magnitude = magnitudeMovimento || Math.sqrt( acelerometroX * acelerometroX + acelerometroY * acelerometroY + acelerometroZ * acelerometroZ ); if (magnitude > 50) { // Aceleração muito alta pode indicar leitura errada ou emulador scoreConfianca *= 0.6; avisos.push( `Magnitude de movimento muito alta (${magnitude.toFixed(2)} m/s²). Pode indicar leitura incorreta.` ); } // Se não há movimento detectado quando deveria haver (em móvel), pode ser suspeito if ( isDesktop !== true && movimentoDetectado === false && variacaoAcelerometro !== undefined && variacaoAcelerometro < 0.001 ) { // Variância muito baixa pode indicar que o dispositivo está parado ou emulador scoreConfianca *= 0.9; avisos.push( 'Nenhum movimento detectado durante o registro. Pode ser normal se o dispositivo estava parado.' ); } // Se há movimento, aumenta confiança if (movimentoDetectado === true) { scoreConfianca = Math.min(scoreConfianca * 1.1, 1.0); } } return { valida: true, // Sempre retorna true - não bloqueia registro, apenas informa através do score motivo: avisos.length > 0 ? avisos[0] : undefined, scoreConfianca: Math.max(0, Math.min(1, scoreConfianca)), avisos }; } /** * 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()), altitude: v.optional(v.union(v.number(), v.null())), altitudeAccuracy: v.optional(v.union(v.number(), v.null())), heading: v.optional(v.union(v.number(), v.null())), speed: v.optional(v.union(v.number(), v.null())), confiabilidadeGPS: v.optional(v.number()), suspeitaSpoofing: v.optional(v.boolean()), motivoSuspeita: v.optional(v.string()), 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()), // Campos de sensores (acelerômetro e giroscópio) sensorDisponivel: v.optional(v.boolean()), permissaoNegada: v.optional(v.boolean()), acelerometro: v.optional( v.object({ x: v.number(), y: v.number(), z: v.number(), movimentoDetectado: v.boolean(), magnitude: v.number(), variacao: v.number(), timestamp: v.number() }) ), giroscopio: v.optional( v.object({ alpha: v.number(), beta: v.number(), gamma: v.number() }) ) }) ), 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'); } // Converter timestamp para data/hora // O timestamp pode vir ajustado com GMT offset do frontend (se GMT !== 0) // ou em UTC puro (se GMT === 0). Usamos UTC methods para extrair os valores // diretamente do timestamp recebido, seja ele ajustado ou não const dataObj = new Date(args.timestamp); // Usar UTC methods porque: // - Se GMT === 0: timestamp está em UTC puro, métodos UTC extraem corretamente // - Se GMT !== 0: timestamp já vem ajustado do frontend, métodos UTC extraem o horário ajustado const hora = dataObj.getUTCHours(); const minuto = dataObj.getUTCMinutes(); const segundo = dataObj.getUTCSeconds(); // Obter data no formato YYYY-MM-DD usando UTC const ano = dataObj.getUTCFullYear(); const mes = String(dataObj.getUTCMonth() + 1).padStart(2, '0'); const dia = String(dataObj.getUTCDate()).padStart(2, '0'); const data = `${ano}-${mes}-${dia}`; // 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'); } // Verificar se funcionário está dispensado de registrar ponto const dispensas = await ctx.db .query('dispensasRegistro') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('ativo'), true)) .collect(); const dataConsulta = new Date(data); for (const dispensa of dispensas) { // Se for isento, sempre está dispensado if (dispensa.isento) { throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)'); } // Verificar se está no período const dataInicio = new Date(dispensa.dataInicio); const dataFim = new Date(dispensa.dataFim); if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { // Verificar hora e minuto se necessário const timestampConsulta = new Date( `${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00` ).getTime(); const timestampInicio = new Date( `${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00` ).getTime(); const timestampFim = new Date( `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` ).getTime(); if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) { throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`); } } // Verificar se expirou (desativar na mutation de registro) const agora = new Date(); const dataFimTimestamp = new Date( `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` ).getTime(); if (agora.getTime() > dataFimTimestamp && !dispensa.isento) { // Desativar dispensa expirada (mutation pode fazer isso) await ctx.db.patch(dispensa._id, { ativo: false }); } } // 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 ); // Validar localização se fornecida e salvar informações detalhadas let validacaoLocalizacao: { valida: boolean; motivo?: string; scoreConfianca: number; avisos: string[]; distanciaIPvsGPS?: number; velocidadeUltimoRegistro?: number; distanciaUltimoRegistro?: number; tempoDecorridoHoras?: number; } | null = null; if (args.informacoesDispositivo?.latitude && args.informacoesDispositivo?.longitude) { validacaoLocalizacao = await validarLocalizacao( ctx, usuario.funcionarioId, args.informacoesDispositivo.latitude, args.informacoesDispositivo.longitude, args.informacoesDispositivo.ipPublico || args.informacoesDispositivo.ipAddress, args.informacoesDispositivo.confiabilidadeGPS ); // Sempre registrar, mesmo com baixa confiabilidade // Mas salvar todas as informações detalhadas para análise posterior const suspeitaFrontend = args.informacoesDispositivo.suspeitaSpoofing; const suspeitaBackend = !validacaoLocalizacao.valida; const baixaConfianca = validacaoLocalizacao.scoreConfianca < 0.5; if (suspeitaFrontend || suspeitaBackend || baixaConfianca) { console.warn( '⚠️ LOCALIZAÇÃO COM BAIXA CONFIABILIDADE DETECTADA (registrando normalmente):', { funcionarioId: usuario.funcionarioId, latitude: args.informacoesDispositivo.latitude, longitude: args.informacoesDispositivo.longitude, confiabilidadeGPSFrontend: args.informacoesDispositivo.confiabilidadeGPS, scoreConfiancaBackend: validacaoLocalizacao.scoreConfianca, suspeitaFrontend: suspeitaFrontend ? args.informacoesDispositivo.motivoSuspeita : null, suspeitaBackend: suspeitaBackend ? validacaoLocalizacao.motivo : null, avisos: validacaoLocalizacao.avisos } ); } } // Validar geofencing (localização permitida) se habilitado let validacaoGeofencing: { dentroRaio: boolean; enderecoMaisProximo?: Id<'enderecosMarcacao'>; distanciaMetros?: number; raioUsado?: number; enderecoEncontrado?: string; avisos: string[]; } | null = null; if ( config.validarLocalizacao !== false && args.informacoesDispositivo?.latitude && args.informacoesDispositivo?.longitude ) { const geofencing = await validarLocalizacaoGeofencingInternal( ctx, usuario.funcionarioId, args.informacoesDispositivo.latitude, args.informacoesDispositivo.longitude, config.toleranciaDistanciaMetros ?? 100 ); validacaoGeofencing = geofencing; // Adicionar avisos de geofencing aos avisos de validação if (geofencing.avisos.length > 0) { if (!validacaoLocalizacao) { validacaoLocalizacao = { valida: true, scoreConfianca: 1, avisos: [] }; } validacaoLocalizacao.avisos.push(...geofencing.avisos); // Reduzir score de confiança se estiver fora do raio if (!geofencing.dentroRaio) { validacaoLocalizacao.scoreConfianca = Math.min(validacaoLocalizacao.scoreConfianca, 0.7); } } } // Validar dados de acelerômetro (não bloqueia registro - apenas informa) const validacaoAcelerometro = validarAcelerometro( args.informacoesDispositivo?.isDesktop, args.informacoesDispositivo?.sensorDisponivel, args.informacoesDispositivo?.permissaoNegada, args.informacoesDispositivo?.acelerometro?.x, args.informacoesDispositivo?.acelerometro?.y, args.informacoesDispositivo?.acelerometro?.z, args.informacoesDispositivo?.acelerometro?.movimentoDetectado, args.informacoesDispositivo?.acelerometro?.magnitude, args.informacoesDispositivo?.acelerometro?.variacao ); // Nota: A validação de acelerômetro não bloqueia o registro - apenas reduz o score de confiança // Apenas câmera e localização são obrigatórias para registrar ponto // Combinar avisos de validação de localização e acelerômetro const todosAvisos = [ ...(validacaoLocalizacao?.avisos || []), ...(validacaoAcelerometro.avisos || []) ]; // Combinar scores de confiança (média ponderada) let scoreFinalConfianca = 1.0; if (validacaoLocalizacao && validacaoAcelerometro) { // GPS tem peso 0.7, acelerômetro tem peso 0.3 scoreFinalConfianca = validacaoLocalizacao.scoreConfianca * 0.7 + validacaoAcelerometro.scoreConfianca * 0.3; } else if (validacaoLocalizacao) { scoreFinalConfianca = validacaoLocalizacao.scoreConfianca; } else if (validacaoAcelerometro) { scoreFinalConfianca = validacaoAcelerometro.scoreConfianca; } // 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, altitude: args.informacoesDispositivo?.altitude, altitudeAccuracy: args.informacoesDispositivo?.altitudeAccuracy, heading: args.informacoesDispositivo?.heading, speed: args.informacoesDispositivo?.speed, confiabilidadeGPS: args.informacoesDispositivo?.confiabilidadeGPS, scoreConfiancaBackend: scoreFinalConfianca, suspeitaSpoofing: args.informacoesDispositivo?.suspeitaSpoofing || (validacaoLocalizacao ? validacaoLocalizacao.scoreConfianca < 0.5 || !validacaoLocalizacao.valida : undefined) || (validacaoAcelerometro ? validacaoAcelerometro.scoreConfianca < 0.5 || !validacaoAcelerometro.valida : undefined), motivoSuspeita: args.informacoesDispositivo?.motivoSuspeita || validacaoLocalizacao?.motivo || validacaoAcelerometro?.motivo || (todosAvisos.length > 0 ? todosAvisos.join('; ') : undefined), // Informações detalhadas de validação (sempre salvar quando houver validação) avisosValidacao: todosAvisos.length > 0 ? todosAvisos : undefined, // Informações de Geofencing enderecoMarcacaoEsperado: validacaoGeofencing?.enderecoMaisProximo, distanciaEnderecoEsperado: validacaoGeofencing?.distanciaMetros, dentroRaioPermitido: validacaoGeofencing?.dentroRaio, enderecoMarcacaoUsado: validacaoGeofencing?.enderecoMaisProximo, raioToleranciaUsado: validacaoGeofencing?.raioUsado, 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, // Dados de sensores (Acelerômetro e Giroscópio) acelerometroX: args.informacoesDispositivo?.acelerometro?.x, acelerometroY: args.informacoesDispositivo?.acelerometro?.y, acelerometroZ: args.informacoesDispositivo?.acelerometro?.z, movimentoDetectado: args.informacoesDispositivo?.acelerometro?.movimentoDetectado, magnitudeMovimento: args.informacoesDispositivo?.acelerometro?.magnitude, variacaoAcelerometro: args.informacoesDispositivo?.acelerometro?.variacao, giroscopioAlpha: args.informacoesDispositivo?.giroscopio?.alpha, giroscopioBeta: args.informacoesDispositivo?.giroscopio?.beta, giroscopioGamma: args.informacoesDispositivo?.giroscopio?.gamma, sensorDisponivel: args.informacoesDispositivo?.sensorDisponivel, permissaoSensorNegada: args.informacoesDispositivo?.permissaoNegada, 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 _refresh: v.optional(v.number()) // Parâmetro usado pelo frontend para forçar refresh }, 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]!; console.log('[listarRegistrosDia] Buscando registros:', { funcionarioId, data }); const registros = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data) ) .order('asc') .collect(); console.log( '[listarRegistrosDia] Registros encontrados:', registros.length, registros.map((r) => ({ _id: r._id, tipo: r.tipo, data: r.data, hora: r.hora, minuto: r.minuto })) ); return registros; } }); /** * Obtém saldo diário de um funcionário para uma data específica */ export const obterSaldoDiario = query({ args: { funcionarioId: v.id('funcionarios'), data: v.string() // YYYY-MM-DD }, handler: async (ctx, args) => { // Buscar banco de horas do dia const bancoHoras = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', args.funcionarioId).eq('data', args.data) ) .first(); if (!bancoHoras) { return { saldoMinutos: 0, horas: 0, minutos: 0, positivo: true }; } const horas = Math.floor(Math.abs(bancoHoras.saldoMinutos) / 60); const minutos = Math.abs(bancoHoras.saldoMinutos) % 60; const positivo = bancoHoras.saldoMinutos >= 0; return { saldoMinutos: bancoHoras.saldoMinutos, horas, minutos, positivo }; } }); /** * 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) { console.warn('[listarRegistrosPeriodo] Usuário não autenticado'); return []; } // Verificar permissão (RH ou TI) // Por enquanto, permitir se tiver funcionarioId ou for admin // TODO: Implementar verificação de permissão adequada // Validar formato das datas if (!args.dataInicio || !args.dataFim) { console.warn('[listarRegistrosPeriodo] Datas não fornecidas'); return []; } // Validar formato YYYY-MM-DD const dataInicioRegex = /^\d{4}-\d{2}-\d{2}$/; if (!dataInicioRegex.test(args.dataInicio) || !dataInicioRegex.test(args.dataFim)) { console.warn('[listarRegistrosPeriodo] Formato de data inválido', { dataInicio: args.dataInicio, dataFim: args.dataFim }); return []; } console.log('[listarRegistrosPeriodo] Buscando registros', { dataInicio: args.dataInicio, dataFim: args.dataFim, funcionarioId: args.funcionarioId, usuarioId: usuario._id }); let registrosFiltrados; // Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente) if (args.funcionarioId) { // Garantir que funcionarioId não é undefined para TypeScript const funcionarioId = args.funcionarioId; // Buscar todos os registros do funcionário const todosRegistrosFuncionario = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId)) .collect(); // Filtrar por período de data usando comparação de strings (formato YYYY-MM-DD) registrosFiltrados = todosRegistrosFuncionario.filter((r) => { // Comparação de strings funciona para formato YYYY-MM-DD return r.data >= args.dataInicio && r.data <= args.dataFim; }); } else { // Se não há funcionário especificado, buscar todos e filtrar (menos eficiente, mas necessário) try { // Tentar usar índice por data primeiro const registros = await ctx.db .query('registrosPonto') .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) .collect(); console.log('[listarRegistrosPeriodo] Registros do índice by_data:', registros.length); // Garantir que as datas estão no formato correto e filtrar novamente para garantir registrosFiltrados = registros.filter((r) => { // Comparação de strings funciona para formato YYYY-MM-DD return r.data >= args.dataInicio && r.data <= args.dataFim; }); console.log('[listarRegistrosPeriodo] Registros após filtro:', registrosFiltrados.length); } catch (error) { console.error('[listarRegistrosPeriodo] Erro ao buscar registros:', error); // Fallback: buscar todos e filtrar manualmente const todosRegistros = await ctx.db.query('registrosPonto').collect(); registrosFiltrados = todosRegistros.filter((r) => { return r.data >= args.dataInicio && r.data <= args.dataFim; }); console.log( '[listarRegistrosPeriodo] Fallback - registros encontrados:', registrosFiltrados.length ); } } console.log( '[listarRegistrosPeriodo] Registros encontrados antes de buscar funcionários:', registrosFiltrados.length ); // 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))); // Buscar saldos diários para cada data/funcionário const saldosPorDataFuncionario: Record = {}; const datasUnicas = new Set(registrosFiltrados.map((r) => `${r.funcionarioId}-${r.data}`)); for (const chave of datasUnicas) { const [funcId, data] = chave.split('-'); const bancoHoras = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcId as Id<'funcionarios'>).eq('data', data) ) .first(); if (bancoHoras) { saldosPorDataFuncionario[chave] = bancoHoras.saldoMinutos; } } console.log( '[listarRegistrosPeriodo] Total de registros a retornar:', registrosFiltrados.length ); // Buscar fotos de perfil dos funcionários const funcionariosComFoto = await Promise.all( funcionarios.map(async (funcionario) => { if (!funcionario) return { funcionario: null, fotoPerfilUrl: null }; let fotoPerfilUrl: string | null = null; const usuario = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id)) .first(); if (usuario?.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } return { funcionario, fotoPerfilUrl }; }) ); return registrosFiltrados.map((registro) => { const funcionarioComFoto = funcionariosComFoto.find( (f) => f.funcionario?._id === registro.funcionarioId ); const funcionario = funcionarioComFoto?.funcionario; const fotoPerfilUrl = funcionarioComFoto?.fotoPerfilUrl || null; const chave = `${registro.funcionarioId}-${registro.data}`; const saldoMinutos = saldosPorDataFuncionario[chave] || 0; const horas = Math.floor(Math.abs(saldoMinutos) / 60); const minutos = Math.abs(saldoMinutos) % 60; const positivo = saldoMinutos >= 0; return { ...registro, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula, descricaoCargo: funcionario.descricaoCargo } : null, fotoPerfilUrl, saldoDiario: { saldoMinutos, horas, minutos, positivo } }; }); } }); /** * 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 funcionarioId: v.optional(v.id('funcionarios')) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { // Retornar estatísticas zeradas quando não autenticado return { totalRegistros: 0, dentroDoPrazo: 0, foraDoPrazo: 0, totalFuncionarios: 0, funcionariosDentroPrazo: 0, funcionariosForaPrazo: 0 }; } // TODO: Verificar permissão (RH ou TI) let 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 fornecido if (args.funcionarioId) { registros = registros.filter((r) => r.funcionarioId === args.funcionarioId); } 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 _refresh: v.optional(v.number()) // Parâmetro usado pelo frontend para forçar refresh }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario || !usuario.funcionarioId) { console.warn('[obterHistoricoESaldoDia] Usuário não autenticado ou sem funcionarioId'); // Retornar dados vazios em vez de lançar erro return { registros: [], cargaHorariaDiaria: 0, horasTrabalhadas: 0, saldoMinutos: 0 }; } // 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(); console.log('[obterHistoricoESaldoDia] Registros encontrados:', registros.length, { funcionarioId: args.funcionarioId, data: args.data }); // 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; const horas = Math.floor(Math.abs(saldoMinutos) / 60); const minutos = Math.abs(saldoMinutos) % 60; const positivo = saldoMinutos >= 0; return { registros, cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, saldoFormatado: { horas, minutos, positivo } }; } }); /** * 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 }; } }); /** * Helper: Verificar se usuário é gestor do funcionário */ async function verificarGestorDoFuncionario( ctx: QueryCtx | MutationCtx, gestorId: Id<'usuarios'>, funcionarioId: Id<'funcionarios'> ): Promise { const membroTime = await ctx.db .query('timesMembros') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('ativo'), true)) .first(); if (!membroTime) return false; const time = await ctx.db.get(membroTime.timeId); if (!time) return false; return time.gestorId === gestorId; } /** * Edita um registro de ponto (homologação pelo gestor) */ export const editarRegistroPonto = mutation({ args: { registroId: v.id('registrosPonto'), horaNova: v.number(), minutoNova: v.number(), motivoId: v.optional(v.string()), motivoTipo: v.optional(v.string()), motivoDescricao: v.optional(v.string()), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Buscar registro const registro = await ctx.db.get(args.registroId); if (!registro) { throw new Error('Registro não encontrado'); } // Verificar se é gestor do funcionário const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, registro.funcionarioId); if (!isGestor) { throw new Error('Você não tem permissão para editar este registro'); } // Salvar dados anteriores const horaAnterior = registro.hora; const minutoAnterior = registro.minuto; // Atualizar registro await ctx.db.patch(args.registroId, { hora: args.horaNova, minuto: args.minutoNova, editadoPorGestor: true }); // Criar registro de homologação const homologacaoId = await ctx.db.insert('homologacoesPonto', { registroId: args.registroId, funcionarioId: registro.funcionarioId, gestorId: usuario._id, horaAnterior, minutoAnterior, horaNova: args.horaNova, minutoNova: args.minutoNova, motivoId: args.motivoId, motivoTipo: args.motivoTipo, motivoDescricao: args.motivoDescricao, observacoes: args.observacoes, criadoEm: Date.now() }); // Atualizar registro com ID da homologação await ctx.db.patch(args.registroId, { homologacaoId }); // Recalcular banco de horas do dia const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (config) { await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config); } return { success: true, homologacaoId }; } }); /** * Ajusta banco de horas (compensar, abonar ou descontar) */ export const ajustarBancoHoras = mutation({ args: { funcionarioId: v.id('funcionarios'), tipoAjuste: v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')), periodoDias: v.number(), periodoHoras: v.number(), periodoMinutos: v.number(), motivoId: v.optional(v.string()), motivoTipo: v.optional(v.string()), motivoDescricao: v.optional(v.string()), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Verificar se é gestor do funcionário const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId); if (!isGestor) { throw new Error('Você não tem permissão para ajustar banco de horas deste funcionário'); } // Calcular ajuste em minutos const ajusteMinutos = args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos; // Aplicar sinal baseado no tipo de ajuste let ajusteFinal = ajusteMinutos; if (args.tipoAjuste === 'descontar') { ajusteFinal = -ajusteMinutos; } // Buscar banco de horas mais recente ou criar um registro de ajuste const hoje = new Date().toISOString().split('T')[0]!; const bancoHorasAtual = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', args.funcionarioId).eq('data', hoje) ) .first(); if (bancoHorasAtual) { // Atualizar saldo do dia atual await ctx.db.patch(bancoHorasAtual._id, { saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal }); } else { // Criar novo registro de banco de horas para o ajuste 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'); } const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); await ctx.db.insert('bancoHoras', { funcionarioId: args.funcionarioId, data: hoje, cargaHorariaDiaria, horasTrabalhadas: 0, saldoMinutos: ajusteFinal, registrosPontoIds: [], calculadoEm: Date.now() }); } // Criar registro de homologação const homologacaoId = await ctx.db.insert('homologacoesPonto', { funcionarioId: args.funcionarioId, gestorId: usuario._id, motivoId: args.motivoId, motivoTipo: args.motivoTipo, motivoDescricao: args.motivoDescricao, observacoes: args.observacoes, tipoAjuste: args.tipoAjuste, periodoDias: args.periodoDias, periodoHoras: args.periodoHoras, periodoMinutos: args.periodoMinutos, ajusteMinutos: ajusteFinal, criadoEm: Date.now() }); return { success: true, homologacaoId, ajusteMinutos: ajusteFinal }; } }); /** * Lista homologações de um funcionário ou time */ export const listarHomologacoes = query({ args: { funcionarioId: v.optional(v.id('funcionarios')) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } let homologacoes; if (args.funcionarioId) { // Verificar se é gestor do funcionário ou o próprio funcionário const funcionarioId = args.funcionarioId; // Garantir que não é undefined const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId); const isProprioFuncionario = usuario.funcionarioId === funcionarioId; if (!isGestor && !isProprioFuncionario) { throw new Error('Você não tem permissão para ver estas homologações'); } homologacoes = await ctx.db .query('homologacoesPonto') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .order('desc') .collect(); } else { // Listar homologações do gestor homologacoes = await ctx.db .query('homologacoesPonto') .withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id)) .order('desc') .collect(); } // Buscar informações adicionais const homologacoesComDetalhes = await Promise.all( homologacoes.map(async (h) => { const funcionario = await ctx.db.get(h.funcionarioId); const gestor = await ctx.db.get(h.gestorId); const registro = h.registroId ? await ctx.db.get(h.registroId) : null; // Buscar foto do perfil do funcionário através do usuário associado let fotoPerfilUrl: string | null = null; if (funcionario) { const usuario = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id)) .first(); if (usuario?.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } } return { ...h, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula } : null, fotoPerfilUrl, gestor: gestor ? { nome: gestor.nome } : null, registro: registro ? { data: registro.data, tipo: registro.tipo } : null }; }) ); return homologacoesComDetalhes; } }); /** * Exclui uma homologação (apenas para gestores) */ export const excluirHomologacao = mutation({ args: { homologacaoId: v.id('homologacoesPonto') }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const homologacao = await ctx.db.get(args.homologacaoId); if (!homologacao) { throw new Error('Homologação não encontrada'); } // Verificar se é gestor do funcionário const isGestor = await verificarGestorDoFuncionario( ctx, usuario._id, homologacao.funcionarioId ); if (!isGestor && homologacao.gestorId !== usuario._id) { throw new Error('Você não tem permissão para excluir esta homologação'); } // Se a homologação estiver vinculada a um registro, remover a referência if (homologacao.registroId) { const registro = await ctx.db.get(homologacao.registroId); if (registro && registro.homologacaoId === args.homologacaoId) { await ctx.db.patch(homologacao.registroId, { homologacaoId: undefined, editadoPorGestor: false }); } } // Excluir homologação await ctx.db.delete(args.homologacaoId); return { success: true }; } }); /** * Obtém opções de motivos de atestados/declarações */ export const obterMotivosAtestados = query({ args: {}, handler: async (ctx) => { // Buscar tipos de atestados e declarações const atestados = await ctx.db.query('atestados').collect(); const tiposUnicos = new Set(); atestados.forEach((a) => { if (a.cid) tiposUnicos.add(`CID: ${a.cid}`); if (a.observacoes) tiposUnicos.add(a.observacoes); }); return { tipos: Array.from(tiposUnicos), opcoesPadrao: [ 'Atestado Médico', 'Declaração', 'Ajuste Administrativo', 'Compensação de Horas', 'Abono', 'Desconto em Folha' ] }; } }); /** * Cria uma dispensa de registro de ponto */ export const criarDispensaRegistro = mutation({ args: { funcionarioId: v.id('funcionarios'), dataInicio: v.string(), // YYYY-MM-DD horaInicio: v.number(), minutoInicio: v.number(), dataFim: v.string(), // YYYY-MM-DD horaFim: v.number(), minutoFim: v.number(), motivo: v.string(), isento: v.boolean() }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Verificar se é gestor do funcionário const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId); if (!isGestor) { throw new Error('Você não tem permissão para criar dispensa para este funcionário'); } // Validar datas const dataInicioObj = new Date(args.dataInicio); const dataFimObj = new Date(args.dataFim); if (dataFimObj < dataInicioObj) { throw new Error('Data fim deve ser maior ou igual à data início'); } // Criar dispensa const dispensaId = await ctx.db.insert('dispensasRegistro', { funcionarioId: args.funcionarioId, gestorId: usuario._id, dataInicio: args.dataInicio, horaInicio: args.horaInicio, minutoInicio: args.minutoInicio, dataFim: args.dataFim, horaFim: args.horaFim, minutoFim: args.minutoFim, motivo: args.motivo, isento: args.isento, ativo: true, criadoEm: Date.now() }); return { success: true, dispensaId }; } }); /** * Remove uma dispensa de registro (cancela) */ export const removerDispensaRegistro = mutation({ args: { dispensaId: v.id('dispensasRegistro') }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const dispensa = await ctx.db.get(args.dispensaId); if (!dispensa) { throw new Error('Dispensa não encontrada'); } // Verificar se é gestor do funcionário const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, dispensa.funcionarioId); if (!isGestor && dispensa.gestorId !== usuario._id) { throw new Error('Você não tem permissão para remover esta dispensa'); } // Desativar dispensa await ctx.db.patch(args.dispensaId, { ativo: false }); return { success: true }; } }); /** * Lista dispensas de registro */ export const listarDispensas = query({ args: { funcionarioId: v.optional(v.id('funcionarios')), apenasAtivas: v.optional(v.boolean()) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } let dispensas; if (args.funcionarioId) { // Verificar se é gestor do funcionário ou o próprio funcionário const funcionarioId = args.funcionarioId; // Garantir que não é undefined const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId); const isProprioFuncionario = usuario.funcionarioId === funcionarioId; if (!isGestor && !isProprioFuncionario) { throw new Error('Você não tem permissão para ver estas dispensas'); } dispensas = await ctx.db .query('dispensasRegistro') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => { if (args.apenasAtivas !== undefined && args.apenasAtivas) { return q.eq(q.field('ativo'), true); } return true; // Retornar todas se apenasAtivas não for especificado }) .order('desc') .collect(); } else { // Listar dispensas do gestor dispensas = await ctx.db .query('dispensasRegistro') .withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id)) .filter((q) => { if (args.apenasAtivas !== undefined && args.apenasAtivas) { return q.eq(q.field('ativo'), true); } return true; // Retornar todas se apenasAtivas não for especificado }) .order('desc') .collect(); } // Buscar informações adicionais const dispensasComDetalhes = await Promise.all( dispensas.map(async (d) => { const funcionario = await ctx.db.get(d.funcionarioId); const gestor = await ctx.db.get(d.gestorId); // Buscar foto do perfil do funcionário através do usuário associado let fotoPerfilUrl: string | null = null; if (funcionario) { const usuario = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id)) .first(); if (usuario?.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } } // Verificar se expirou (se não for isento) let expirada = false; if (!d.isento) { const agora = new Date(); const dataFimTimestamp = new Date( `${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00` ).getTime(); expirada = agora.getTime() > dataFimTimestamp; } return { ...d, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula } : null, fotoPerfilUrl, gestor: gestor ? { nome: gestor.nome } : null, expirada }; }) ); return dispensasComDetalhes; } }); /** * Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica */ export const verificarDispensaAtiva = query({ args: { funcionarioId: v.id('funcionarios'), data: v.string(), // YYYY-MM-DD hora: v.optional(v.number()), minuto: v.optional(v.number()) }, handler: async (ctx, args) => { const dispensas = await ctx.db .query('dispensasRegistro') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) .filter((q) => q.eq(q.field('ativo'), true)) .collect(); const dataConsulta = new Date(args.data); for (const dispensa of dispensas) { // Se for isento, sempre está dispensado if (dispensa.isento) { return { dispensado: true, dispensa, motivo: 'Isento de registro (caso excepcional)' }; } // Verificar se está no período const dataInicio = new Date(dispensa.dataInicio); const dataFim = new Date(dispensa.dataFim); // Se a data está dentro do período if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { // Se hora e minuto foram fornecidos, verificar também if (args.hora !== undefined && args.minuto !== undefined) { const timestampConsulta = new Date( `${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00` ).getTime(); const timestampInicio = new Date( `${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00` ).getTime(); const timestampFim = new Date( `${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00` ).getTime(); if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) { return { dispensado: true, dispensa, motivo: dispensa.motivo }; } } else { // Apenas verificar data return { dispensado: true, dispensa, motivo: dispensa.motivo }; } } } return { dispensado: false, dispensa: null, motivo: null }; } });