import { v } from 'convex/values'; import { mutation, query, internalMutation } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id, Doc } from './_generated/dataModel'; import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao'; import { internal, api } from './_generated/api'; /** * 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; let velocidadeUltimoRegistro: number | undefined; let distanciaUltimoRegistro: number | undefined; let tempoDecorridoHoras: number | 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'registrar' }); 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; } /** * Valida sequência de registros antes de permitir novo registro * Retorna erro se a sequência não for válida */ async function validarSequenciaRegistro( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string, tipoEsperado: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' ): Promise<{ valido: boolean; motivo?: string }> { const registrosHoje = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .order('desc') .collect(); // Se não há registros, só pode ser entrada if (registrosHoje.length === 0) { if (tipoEsperado !== 'entrada') { return { valido: false, motivo: 'Primeiro registro do dia deve ser uma entrada' }; } return { valido: true }; } const ultimoRegistro = registrosHoje[0]; // Validar sequência lógica switch (tipoEsperado) { case 'entrada': // Só pode registrar entrada se o último foi saída (novo dia) ou não há registros if (ultimoRegistro.tipo !== 'saida') { return { valido: false, motivo: `Não é possível registrar entrada. Último registro foi: ${ultimoRegistro.tipo}. Esperado: saída do dia anterior.` }; } break; case 'saida_almoco': // Só pode registrar saída almoço se o último foi entrada if (ultimoRegistro.tipo !== 'entrada') { return { valido: false, motivo: `Não é possível registrar saída para almoço. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar entrada primeiro.` }; } break; case 'retorno_almoco': // Só pode registrar retorno almoço se o último foi saída almoço if (ultimoRegistro.tipo !== 'saida_almoco') { return { valido: false, motivo: `Não é possível registrar retorno do almoço. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar saída para almoço primeiro.` }; } break; case 'saida': // Só pode registrar saída se o último foi retorno almoço ou entrada (sem intervalo) if (ultimoRegistro.tipo !== 'retorno_almoco' && ultimoRegistro.tipo !== 'entrada') { return { valido: false, motivo: `Não é possível registrar saída. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar retorno do almoço ou ter apenas entrada.` }; } // Se último foi entrada, verificar se não há saída almoço pendente if (ultimoRegistro.tipo === 'entrada') { const temSaidaAlmoco = registrosHoje.some((r) => r.tipo === 'saida_almoco'); if (temSaidaAlmoco) { return { valido: false, motivo: 'Não é possível registrar saída. Há saída para almoço registrada, mas falta o retorno do almoço.' }; } } break; } return { valido: true }; } /** * 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'registrar' }); 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 existe const funcionario = await ctx.db.get(usuario.funcionarioId); if (!funcionario) { throw new Error('Funcionário não encontrado'); } // Bloquear registro de ponto para funcionários em férias ou licença if (funcionario.statusFerias === 'em_ferias') { throw new Error('Não é possível registrar ponto: funcionário está em férias.'); } if (funcionario.statusFerias === 'em_licenca') { throw new Error('Não é possível registrar ponto: funcionário está em licença.'); } // 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}`; // Bloquear registro de ponto quando houver ausência aprovada ativa na data const ausenciaInfo = await verificarAusenciaAprovada(ctx, usuario.funcionarioId, data); if (ausenciaInfo.temAusencia) { throw new Error( ausenciaInfo.motivo || 'Não é possível registrar ponto: existe uma ausência aprovada ativa para esta data.' ); } // 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); // Validar sequência de registros const validacaoSequencia = await validarSequenciaRegistro( ctx, usuario.funcionarioId, data, tipo ); if (!validacaoSequencia.valido) { throw new Error(validacaoSequencia.motivo || 'Sequência de registros inválida'); } // 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) => { try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'ver' }); } catch { return []; } 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); // 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) => { try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'ver' }); } catch { return []; } const usuario = await getCurrentUserFunction(ctx); if (!usuario) { console.warn('[listarRegistrosPeriodo] Usuário não autenticado'); return []; } // Permissão já verificada acima (ponto.ver) // 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) => { try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'ver' }); } catch { return { totalRegistros: 0, dentroDoPrazo: 0, foraDoPrazo: 0, totalFuncionarios: 0, funcionariosDentroPrazo: 0, funcionariosForaPrazo: 0 }; } 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 }; } // Permissão já verificada acima (ponto.ver) 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'ver' }); 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'); } // Permissão já verificada acima (ponto.ver) 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 * Trata casos incompletos de forma mais robusta */ function calcularHorasTrabalhadas( registros: Array<{ tipo: string; hora: number; minuto: number; }> ): number { if (registros.length === 0) { return 0; } // 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 totalMinutos = 0; let entradaPendente: { hora: number; minuto: number } | null = null; // Processar registros sequencialmente para capturar todos os períodos de trabalho // Isso permite calcular múltiplas entradas/saídas no mesmo dia for (const registro of registrosOrdenados) { const minutosRegistro = registro.hora * 60 + registro.minuto; if (registro.tipo === 'entrada') { // Se já havia uma entrada pendente sem saída, ignorar a anterior (inconsistência) // e usar a nova entrada entradaPendente = { hora: registro.hora, minuto: registro.minuto }; } else if (registro.tipo === 'saida_almoco') { // Se há entrada pendente, calcular período da manhã if (entradaPendente) { const minutosEntrada = entradaPendente.hora * 60 + entradaPendente.minuto; if (minutosRegistro > minutosEntrada) { totalMinutos += minutosRegistro - minutosEntrada; } // Limpar entrada pendente após saída almoço (aguardar retorno) entradaPendente = null; } } else if (registro.tipo === 'retorno_almoco') { // Marcar como nova entrada para período da tarde entradaPendente = { hora: registro.hora, minuto: registro.minuto }; } else if (registro.tipo === 'saida') { // Se há entrada pendente (pode ser entrada inicial ou retorno almoço), calcular período if (entradaPendente) { const minutosEntrada = entradaPendente.hora * 60 + entradaPendente.minuto; if (minutosRegistro > minutosEntrada) { totalMinutos += minutosRegistro - minutosEntrada; } // Limpar entrada pendente após saída entradaPendente = null; } } } return totalMinutos; } /** * Verifica se há atestado médico ativo para o funcionário na data */ async function verificarAtestadoAtivo( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string ): Promise<{ temAtestado: boolean; atestadoId?: string; motivo?: string }> { const dataObj = new Date(data); const atestados = await ctx.db .query('atestados') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .collect(); for (const atestado of atestados) { const dataInicio = new Date(atestado.dataInicio); const dataFim = new Date(atestado.dataFim); if (dataObj >= dataInicio && dataObj <= dataFim) { return { temAtestado: true, atestadoId: atestado._id, motivo: `Atestado Médico - CID: ${atestado.cid || 'N/A'}` }; } } return { temAtestado: false }; } /** * Verifica se há licença ativa para o funcionário na data */ async function verificarLicencaAtiva( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string ): Promise<{ temLicenca: boolean; licencaId?: string; motivo?: string }> { const dataObj = new Date(data); const licencas = await ctx.db .query('licencas') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .collect(); for (const licenca of licencas) { const dataInicio = new Date(licenca.dataInicio); const dataFim = new Date(licenca.dataFim); if (dataObj >= dataInicio && dataObj <= dataFim) { const tipoLicenca = licenca.tipo === 'maternidade' ? 'Licença Maternidade' : licenca.tipo === 'paternidade' ? 'Licença Paternidade' : 'Licença'; return { temLicenca: true, licencaId: licenca._id, motivo: tipoLicenca }; } } return { temLicenca: false }; } /** * Verifica se há ausência aprovada para o funcionário na data */ async function verificarAusenciaAprovada( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string ): Promise<{ temAusencia: boolean; ausenciaId?: string; motivo?: string }> { const dataObj = new Date(data); const ausencias = await ctx.db .query('solicitacoesAusencias') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('status'), 'aprovado')) .collect(); for (const ausencia of ausencias) { const dataInicio = new Date(ausencia.dataInicio); const dataFim = new Date(ausencia.dataFim); if (dataObj >= dataInicio && dataObj <= dataFim) { return { temAusencia: true, ausenciaId: ausencia._id, motivo: `Ausência Aprovada - ${ausencia.motivo}` }; } } return { temAusencia: false }; } /** * Verifica ajustes manuais aplicados no dia */ async function obterAjustesManuais( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string ): Promise< Array<{ tipo: 'abonar' | 'descontar' | 'compensar'; valorMinutos: number; motivo?: string; ajusteId?: Id<'ajustesBancoHoras'>; }> > { const ajustes = await ctx.db .query('ajustesBancoHoras') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('dataAplicacao', data) ) .filter((q) => q.and(q.eq(q.field('aplicado'), true), q.eq(q.field('motivoTipo'), 'manual'))) .collect(); return ajustes.map((a) => ({ tipo: a.tipo, valorMinutos: a.valorMinutos, motivo: a.motivoDescricao, ajusteId: a._id })); } /** * Detecta inconsistências no banco de horas do dia */ async function detectarInconsistencias( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, data: string, registrosPontoIds: Array>, atestadoInfo: { temAtestado: boolean; atestadoId?: string }, licencaInfo: { temLicenca: boolean; licencaId?: string }, ausenciaInfo: { temAusencia: boolean; ausenciaId?: string } ): Promise>> { const inconsistenciaIds: Array> = []; // Verificar se há registro de ponto quando há atestado if (atestadoInfo.temAtestado && registrosPontoIds.length > 0) { const inconsistenciaId = await ctx.db.insert('inconsistenciasBancoHoras', { funcionarioId, tipo: 'ponto_com_atestado', descricao: `Registro de ponto detectado em dia com atestado médico ativo`, dataDetectada: data, dataInconsistencia: data, status: 'pendente', criadoEm: Date.now() }); inconsistenciaIds.push(inconsistenciaId); } // Verificar se há registro de ponto quando há licença if (licencaInfo.temLicenca && registrosPontoIds.length > 0) { const inconsistenciaId = await ctx.db.insert('inconsistenciasBancoHoras', { funcionarioId, tipo: 'ponto_com_licenca', descricao: `Registro de ponto detectado em dia com licença ativa`, dataDetectada: data, dataInconsistencia: data, status: 'pendente', criadoEm: Date.now() }); inconsistenciaIds.push(inconsistenciaId); } // Verificar se há registro de ponto quando há ausência aprovada if (ausenciaInfo.temAusencia && registrosPontoIds.length > 0) { const inconsistenciaId = await ctx.db.insert('inconsistenciasBancoHoras', { funcionarioId, tipo: 'ponto_com_ausencia', descricao: `Registro de ponto detectado em dia com ausência aprovada`, dataDetectada: data, dataInconsistencia: data, status: 'pendente', criadoEm: Date.now() }); inconsistenciaIds.push(inconsistenciaId); } return inconsistenciaIds; } /** * Aplica ajuste automático baseado em atestado, licença ou ausência */ async function aplicarAjusteAutomatico( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, data: string, cargaHorariaDiaria: number, motivoTipo: 'atestado' | 'licenca' | 'ausencia', motivoId: string, motivoDescricao: string ): Promise> { const ajusteId = await ctx.db.insert('ajustesBancoHoras', { funcionarioId, tipo: 'abonar', motivoTipo, motivoId, motivoDescricao, valorMinutos: cargaHorariaDiaria, // Abonar a carga horária completa do dia dataAplicacao: data, aplicado: true, criadoEm: Date.now(), aplicadoEm: Date.now() }); return ajusteId; } /** * Atualiza ou cria registro de banco de horas para o dia (versão completa) */ 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); // Verificar atestados, licenças e ausências ativos const [atestadoInfo, licencaInfo, ausenciaInfo, ajustesManuais] = await Promise.all([ verificarAtestadoAtivo(ctx, funcionarioId, data), verificarLicencaAtiva(ctx, funcionarioId, data), verificarAusenciaAprovada(ctx, funcionarioId, data), obterAjustesManuais(ctx, funcionarioId, data) ]); // Calcular horas trabalhadas const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia); // Determinar tipo do dia e motivo let tipoDia: 'normal' | 'atestado' | 'licenca' | 'ausencia' | 'abonado' | 'descontado' = 'normal'; let motivoAbono: string | undefined = undefined; const ajustesIds: Array> = []; // Aplicar ajustes automáticos se houver atestado, licença ou ausência if (atestadoInfo.temAtestado) { tipoDia = 'atestado'; motivoAbono = atestadoInfo.motivo; if (atestadoInfo.atestadoId) { const ajusteId = await aplicarAjusteAutomatico( ctx, funcionarioId, data, cargaHorariaDiaria, 'atestado', atestadoInfo.atestadoId, atestadoInfo.motivo || 'Atestado Médico' ); ajustesIds.push(ajusteId); } } else if (licencaInfo.temLicenca) { tipoDia = 'licenca'; motivoAbono = licencaInfo.motivo; if (licencaInfo.licencaId) { const ajusteId = await aplicarAjusteAutomatico( ctx, funcionarioId, data, cargaHorariaDiaria, 'licenca', licencaInfo.licencaId, licencaInfo.motivo || 'Licença' ); ajustesIds.push(ajusteId); } } else if (ausenciaInfo.temAusencia) { tipoDia = 'ausencia'; motivoAbono = ausenciaInfo.motivo; if (ausenciaInfo.ausenciaId) { const ajusteId = await aplicarAjusteAutomatico( ctx, funcionarioId, data, cargaHorariaDiaria, 'ausencia', ausenciaInfo.ausenciaId, ausenciaInfo.motivo || 'Ausência Aprovada' ); ajustesIds.push(ajusteId); } } // Aplicar ajustes manuais let ajusteManualTotal = 0; for (const ajuste of ajustesManuais) { ajusteManualTotal += ajuste.valorMinutos; if (ajuste.tipo === 'abonar') { if (tipoDia === 'normal') { tipoDia = 'abonado'; } if (!motivoAbono) { motivoAbono = ajuste.motivo || 'Abono Manual'; } } else if (ajuste.tipo === 'descontar') { if (tipoDia === 'normal') { tipoDia = 'descontado'; } } // Adicionar ID do ajuste manual if (ajuste.ajusteId) { ajustesIds.push(ajuste.ajusteId); } } // Calcular saldo considerando ajustes // Saldo base = horas trabalhadas - carga horária let saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; // Adicionar ajustes automáticos (abonos por atestado/licença/ausência) if (atestadoInfo.temAtestado || licencaInfo.temLicenca || ausenciaInfo.temAusencia) { saldoMinutos += cargaHorariaDiaria; // Abonar carga horária completa } // Adicionar/subtrair ajustes manuais saldoMinutos += ajusteManualTotal; // Detectar inconsistências const registrosPontoIds = registrosDoDia.map((r) => r._id); const inconsistenciaIds = await detectarInconsistencias( ctx, funcionarioId, data, registrosPontoIds, atestadoInfo, licencaInfo, ausenciaInfo ); // 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(); if (bancoHorasExistente) { // Atualizar existente await ctx.db.patch(bancoHorasExistente._id, { cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, registrosPontoIds, ajustesIds: ajustesIds.length > 0 ? ajustesIds : undefined, motivoAbono, tipoDia, inconsistenciasIds: inconsistenciaIds.length > 0 ? inconsistenciaIds : undefined, calculadoEm: Date.now() }); } else { // Criar novo await ctx.db.insert('bancoHoras', { funcionarioId, data, cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, registrosPontoIds, ajustesIds: ajustesIds.length > 0 ? ajustesIds : undefined, motivoAbono, tipoDia, inconsistenciasIds: inconsistenciaIds.length > 0 ? inconsistenciaIds : undefined, calculadoEm: Date.now() }); } // Atualizar banco de horas mensal const mes = data.substring(0, 7); // YYYY-MM // Verificar se estamos editando um mês passado const hoje = new Date(); const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; const estaEditandoMesPassado = mes < mesAtual; // Se estamos editando um mês passado, recalcular em cascata para atualizar meses seguintes await calcularBancoHorasMensal(ctx, funcionarioId, mes, estaEditandoMesPassado); } /** * 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) => { try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'ver' }); } catch { return { registros: [], cargaHorariaDiaria: 0, horasTrabalhadas: 0, saldoMinutos: 0 }; } 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 }; } // Permissão já verificada acima (ponto.ver) // 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) // 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 }; } }); /** * Recalcula meses seguintes em cascata quando um mês anterior é atualizado * Isso garante que os saldos iniciais dos meses seguintes sejam atualizados corretamente */ async function recalcularMesesSeguintes( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, mesAtualizado: string // YYYY-MM do mês que foi atualizado ): Promise { const hoje = new Date(); const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; // Se o mês atualizado já é o mês atual ou futuro, não precisa recalcular nada if (mesAtualizado >= mesAtual) { return; } // Recalcular todos os meses do mês seguinte ao atualizado até o mês atual // Calcular primeiro mês a recalcular (mês seguinte ao atualizado) const [anoAtualizado, mesNumAtualizado] = mesAtualizado.split('-').map(Number); let anoIter = anoAtualizado; let mesNumIter = mesNumAtualizado + 1; if (mesNumIter > 12) { mesNumIter = 1; anoIter += 1; } // Continuar enquanto o mês iterado for menor ou igual ao mês atual while (true) { const mesIterStr = `${anoIter}-${String(mesNumIter).padStart(2, '0')}`; // Se passou do mês atual, parar if (mesIterStr > mesAtual) { break; } // Verificar se existe registro mensal para este mês const bancoMensalExistente = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', funcionarioId).eq('mes', mesIterStr) ) .first(); // Se existe registro, recalcular (o saldo inicial mudou porque o mês anterior mudou) if (bancoMensalExistente) { await calcularBancoHorasMensal(ctx, funcionarioId, mesIterStr, false); // false = não recalcular cascata novamente } // Avançar para o próximo mês mesNumIter += 1; if (mesNumIter > 12) { mesNumIter = 1; anoIter += 1; } } } /** * Calcula e atualiza banco de horas mensal para um funcionário * Esta função deve ser chamada após atualizações no banco de horas diário * @param recalcularCascata - Se true, recalcula automaticamente os meses seguintes (padrão: true) */ async function calcularBancoHorasMensal( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, mes: string, // YYYY-MM recalcularCascata: boolean = true // Por padrão, recalcula em cascata ): Promise { // Buscar todos os bancoHoras do mês const dataInicio = `${mes}-01`; // Calcular último dia do mês: criar data do primeiro dia do mês seguinte e subtrair 1 dia const [ano, mesNum] = mes.split('-').map(Number); const ultimoDia = new Date(ano, mesNum, 0).getDate(); // Dia 0 do mês seguinte = último dia do mês atual const dataFim = `${mes}-${String(ultimoDia).padStart(2, '0')}`; const bancosHorasDoMes = await ctx.db .query('bancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => { const data = q.field('data'); return q.and(q.gte(data, dataInicio), q.lte(data, dataFim)); }) .collect(); // Calcular saldo do mês anterior para obter saldo inicial const mesAnterior = new Date(`${mes}-01`); mesAnterior.setMonth(mesAnterior.getMonth() - 1); const mesAnteriorStr = `${mesAnterior.getFullYear()}-${String(mesAnterior.getMonth() + 1).padStart(2, '0')}`; const bancoMensalAnterior = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', funcionarioId).eq('mes', mesAnteriorStr) ) .first(); const saldoInicialMinutos = bancoMensalAnterior?.saldoFinalMinutos || 0; // Calcular estatísticas do mês const diasTrabalhados = bancosHorasDoMes.length; const saldoMesMinutos = bancosHorasDoMes.reduce((acc, bh) => acc + bh.saldoMinutos, 0); const saldoFinalMinutos = saldoInicialMinutos + saldoMesMinutos; // Separar horas extras e déficit const horasExtras = bancosHorasDoMes .filter((bh) => bh.saldoMinutos > 0) .reduce((acc, bh) => acc + bh.saldoMinutos, 0); const horasDeficit = Math.abs( bancosHorasDoMes .filter((bh) => bh.saldoMinutos < 0) .reduce((acc, bh) => acc + bh.saldoMinutos, 0) ); // Calcular totais de ajustes, abonos e descontos let totalAjustes = 0; let totalAbonos = 0; let totalDescontos = 0; let inconsistenciasResolvidas = 0; for (const bh of bancosHorasDoMes) { // Contar ajustes if (bh.ajustesIds && bh.ajustesIds.length > 0) { const ajustes = await Promise.all(bh.ajustesIds.map((id) => ctx.db.get(id))); for (const ajuste of ajustes) { if (ajuste) { totalAjustes += Math.abs(ajuste.valorMinutos); if (ajuste.tipo === 'abonar') { totalAbonos += ajuste.valorMinutos; } else if (ajuste.tipo === 'descontar') { totalDescontos += Math.abs(ajuste.valorMinutos); } } } } // Contar inconsistências resolvidas if (bh.inconsistenciasIds && bh.inconsistenciasIds.length > 0) { const inconsistencias = await Promise.all(bh.inconsistenciasIds.map((id) => ctx.db.get(id))); inconsistenciasResolvidas += inconsistencias.filter( (i) => i && i.status === 'resolvida' ).length; } } const agora = Date.now(); // Buscar ou criar registro mensal const bancoMensalExistente = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', funcionarioId).eq('mes', mes)) .first(); if (bancoMensalExistente) { // Atualizar existente await ctx.db.patch(bancoMensalExistente._id, { saldoInicialMinutos, saldoFinalMinutos, saldoMesMinutos, diasTrabalhados, horasExtras, horasDeficit, totalAjustes, totalAbonos, totalDescontos, inconsistenciasResolvidas, atualizadoEm: agora }); } else { // Criar novo await ctx.db.insert('bancoHorasMensal', { funcionarioId, mes, saldoInicialMinutos, saldoFinalMinutos, saldoMesMinutos, diasTrabalhados, horasExtras, horasDeficit, totalAjustes, totalAbonos, totalDescontos, inconsistenciasResolvidas, calculadoEm: agora, atualizadoEm: agora }); } // Recalcular meses seguintes em cascata se solicitado if (recalcularCascata) { await recalcularMesesSeguintes(ctx, funcionarioId, mes); } } /** * Obtém banco de horas mensal de um funcionário */ export const obterBancoHorasMensal = query({ args: { funcionarioId: v.id('funcionarios'), mes: v.string() // YYYY-MM }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) const bancoMensal = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', args.funcionarioId).eq('mes', args.mes) ) .first(); if (!bancoMensal) { // Retornar valores zerados se não existe return { mes: args.mes, saldoInicialMinutos: 0, saldoFinalMinutos: 0, saldoMesMinutos: 0, diasTrabalhados: 0, horasExtras: 0, horasDeficit: 0, saldoFormatado: { inicial: { horas: 0, minutos: 0, positivo: true }, final: { horas: 0, minutos: 0, positivo: true }, mes: { horas: 0, minutos: 0, positivo: true } } }; } // Formatar valores const formatarSaldo = (minutos: number) => { const horas = Math.floor(Math.abs(minutos) / 60); const mins = Math.abs(minutos) % 60; return { horas, minutos: mins, positivo: minutos >= 0 }; }; return { ...bancoMensal, saldoFormatado: { inicial: formatarSaldo(bancoMensal.saldoInicialMinutos), final: formatarSaldo(bancoMensal.saldoFinalMinutos), mes: formatarSaldo(bancoMensal.saldoMesMinutos) } }; } }); /** * Lista histórico mensal de banco de horas de um funcionário */ export const listarHistoricoMensal = query({ args: { funcionarioId: v.id('funcionarios'), mesInicio: v.optional(v.string()), // YYYY-MM mesFim: v.optional(v.string()) // YYYY-MM }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) let query = ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)); // Filtrar por período se fornecido if (args.mesInicio || args.mesFim) { query = query.filter((q) => { const mes = q.field('mes'); if (args.mesInicio && args.mesFim) { return q.and(q.gte(mes, args.mesInicio), q.lte(mes, args.mesFim)); } else if (args.mesInicio) { return q.gte(mes, args.mesInicio); } else if (args.mesFim) { return q.lte(mes, args.mesFim); } return true; }); } const bancosMensais = await query.order('desc').collect(); // Formatar valores const formatarSaldo = (minutos: number) => { const horas = Math.floor(Math.abs(minutos) / 60); const mins = Math.abs(minutos) % 60; return { horas, minutos: mins, positivo: minutos >= 0 }; }; return bancosMensais.map((bm) => ({ ...bm, saldoFormatado: { inicial: formatarSaldo(bm.saldoInicialMinutos), final: formatarSaldo(bm.saldoFinalMinutos), mes: formatarSaldo(bm.saldoMesMinutos), extras: formatarSaldo(bm.horasExtras), deficit: formatarSaldo(-bm.horasDeficit) } })); } }); /** * Envia notificações push para alertas de banco de horas * Esta função deve ser chamada periodicamente (via cron ou scheduler) */ export const enviarNotificacoesAlertasBancoHoras = internalMutation({ args: {}, handler: async (ctx) => { // Buscar todos os funcionários ativos (sem data de desligamento) const todosFuncionarios = await ctx.db.query('funcionarios').collect(); const funcionarios = todosFuncionarios.filter((f) => !f.desligamentoData); let notificacoesEnviadas = 0; for (const funcionario of funcionarios) { // Buscar usuário associado const usuario = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id)) .first(); if (!usuario) continue; // Verificar alertas const hoje = new Date(); const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; const bancoMensal = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', funcionario._id).eq('mes', mesAtual) ) .first(); if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) { const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; const minutosNegativos = Math.abs(bancoMensal.saldoFinalMinutos) % 60; // Enviar notificação apenas se saldo negativo for significativo (> 1 hora) if (horasNegativas >= 1) { const titulo = horasNegativas > 8 ? '⚠️ Alerta Crítico: Saldo Negativo de Banco de Horas' : '⚠️ Atenção: Saldo Negativo de Banco de Horas'; const corpo = `Seu saldo acumulado está negativo em ${Math.floor(horasNegativas)}h ${minutosNegativos}min. Considere compensar horas ou entrar em contato com seu gestor.`; // Enviar push notification await ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, { usuarioId: usuario._id, titulo, corpo, data: { tipo: 'banco_horas_alerta' } }); notificacoesEnviadas++; } } } return { notificacoesEnviadas }; } }); /** * Verifica alertas de banco de horas (saldo negativo, etc) */ export const verificarAlertasBancoHoras = query({ args: { funcionarioId: v.id('funcionarios') }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) // Buscar banco de horas mensal mais recente const hoje = new Date(); const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; const bancoMensal = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', args.funcionarioId).eq('mes', mesAtual) ) .first(); const alertas: Array<{ tipo: 'saldo_negativo' | 'saldo_negativo_critico' | 'dias_sem_registro'; severidade: 'warning' | 'error'; mensagem: string; valor?: number; }> = []; if (bancoMensal) { // Alerta 1: Saldo negativo acumulado if (bancoMensal.saldoFinalMinutos < 0) { const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; alertas.push({ tipo: horasNegativas > 8 ? 'saldo_negativo_critico' : 'saldo_negativo', severidade: horasNegativas > 8 ? 'error' : 'warning', mensagem: `Saldo negativo acumulado de ${Math.floor(Math.abs(bancoMensal.saldoFinalMinutos) / 60)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min`, valor: bancoMensal.saldoFinalMinutos }); } } // Verificar dias sem registro nos últimos 7 dias const ultimos7Dias: string[] = []; for (let i = 0; i < 7; i++) { const data = new Date(); data.setDate(data.getDate() - i); ultimos7Dias.push(data.toISOString().split('T')[0]!); } const registrosRecentes = await ctx.db .query('bancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) .filter((q) => { const data = q.field('data'); return q.or(...ultimos7Dias.map((dia) => q.eq(data, dia))); }) .collect(); const diasComRegistro = new Set(registrosRecentes.map((r) => r.data)); const diasSemRegistro = ultimos7Dias.filter((dia) => !diasComRegistro.has(dia)); if (diasSemRegistro.length >= 3) { alertas.push({ tipo: 'dias_sem_registro', severidade: 'warning', mensagem: `${diasSemRegistro.length} dias sem registro de ponto nos últimos 7 dias`, valor: diasSemRegistro.length }); } return { alertas, temAlertas: alertas.length > 0, bancoMensalAtual: bancoMensal }; } }); /** * 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); 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 (isso já atualiza o mensal automaticamente) 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()) }, returns: v.object({ success: v.boolean(), homologacaoId: v.id('homologacoesPonto'), ajusteId: v.id('ajustesBancoHoras'), ajusteMinutos: v.number() }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ajustar' }); 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]!; // Criar registro de ajuste na nova tabela const ajusteId = await ctx.db.insert('ajustesBancoHoras', { funcionarioId: args.funcionarioId, tipo: args.tipoAjuste, motivoTipo: 'manual', motivoId: args.motivoId, motivoDescricao: args.motivoDescricao || `Ajuste ${args.tipoAjuste}`, valorMinutos: ajusteFinal, dataAplicacao: hoje, gestorId: usuario._id, observacoes: args.observacoes, aplicado: false, criadoEm: Date.now() }); 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 e adicionar ajuste await ctx.db.patch(bancoHorasAtual._id, { saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal, ajustesIds: [...(bancoHorasAtual.ajustesIds || []), ajusteId], tipoDia: args.tipoAjuste === 'abonar' ? 'abonado' : args.tipoAjuste === 'descontar' ? 'descontado' : bancoHorasAtual.tipoDia, motivoAbono: args.motivoDescricao || bancoHorasAtual.motivoAbono }); } 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: [], ajustesIds: [ajusteId], tipoDia: args.tipoAjuste === 'abonar' ? 'abonado' : 'descontado', motivoAbono: args.motivoDescricao || `Ajuste ${args.tipoAjuste}`, calculadoEm: Date.now() }); } // Marcar ajuste como aplicado await ctx.db.patch(ajusteId, { aplicado: true, aplicadoEm: Date.now() }); // Recalcular banco de horas mensal após ajuste const mes = hoje.substring(0, 7); // YYYY-MM // Verificar se estamos ajustando um mês passado const hojeDate = new Date(); const mesAtual = `${hojeDate.getFullYear()}-${String(hojeDate.getMonth() + 1).padStart(2, '0')}`; const estaAjustandoMesPassado = mes < mesAtual; // Se estamos ajustando um mês passado, recalcular em cascata para atualizar meses seguintes await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAjustandoMesPassado); // Criar registro de homologação (mantido para compatibilidade) 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, ajusteId, 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); // 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'editar' }); 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; } }); /** * Obtém estatísticas gerenciais do banco de horas para RH */ export const obterEstatisticasBancoHorasGerencial = query({ args: { mes: v.string(), // YYYY-MM funcionarioId: v.optional(v.id('funcionarios')) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) // Buscar todos os bancos de horas do mês let bancosMensais = await ctx.db .query('bancoHorasMensal') .withIndex('by_mes', (q) => q.eq('mes', args.mes)) .collect(); // Filtrar por funcionário se fornecido if (args.funcionarioId) { bancosMensais = bancosMensais.filter((b) => b.funcionarioId === args.funcionarioId); } // Buscar informações dos funcionários const funcionariosComDetalhes = await Promise.all( bancosMensais.map(async (banco) => { const funcionario = await ctx.db.get(banco.funcionarioId); if (!funcionario) { return { ...banco, funcionario: null }; } // Buscar foto do perfil do funcionário através do usuário associado let fotoPerfilUrl: string | null = null; const usuario = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id)) .first(); if (usuario?.fotoPerfil) { fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil); } return { ...banco, funcionario: { nome: funcionario.nome, matricula: funcionario.matricula, fotoPerfilUrl } }; }) ); // Calcular estatísticas gerais const totalFuncionarios = funcionariosComDetalhes.length; const funcionariosPositivos = funcionariosComDetalhes.filter( (f) => f.saldoFinalMinutos >= 0 ).length; const funcionariosNegativos = totalFuncionarios - funcionariosPositivos; const totalHorasExtras = funcionariosComDetalhes.reduce((acc, f) => acc + f.horasExtras, 0); const totalDeficit = funcionariosComDetalhes.reduce((acc, f) => acc + f.horasDeficit, 0); return { mes: args.mes, totalFuncionarios, funcionariosPositivos, funcionariosNegativos, totalHorasExtras, totalDeficit, funcionarios: funcionariosComDetalhes }; } }); /** * Lista histórico de alterações no banco de horas (homologações e ajustes) */ export const listarHistoricoAlteracoesBancoHoras = query({ args: { funcionarioId: v.id('funcionarios'), mes: v.optional(v.string()) // YYYY-MM - se fornecido, filtra por mês }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) // Buscar homologações do funcionário let homologacoes = await ctx.db .query('homologacoesPonto') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) .order('desc') .collect(); // Filtrar por mês se fornecido if (args.mes) { const mesHomologacao = args.mes; homologacoes = homologacoes.filter((h) => { const dataHomologacao = new Date(h.criadoEm); const mesHomologacaoStr = `${dataHomologacao.getFullYear()}-${String(dataHomologacao.getMonth() + 1).padStart(2, '0')}`; return mesHomologacaoStr === mesHomologacao; }); } // Buscar informações adicionais const historicoComDetalhes = await Promise.all( homologacoes.map(async (h) => { const gestor = await ctx.db.get(h.gestorId); const registro = h.registroId ? await ctx.db.get(h.registroId) : null; // Determinar tipo de alteração let tipoAlteracao: 'edicao_registro' | 'ajuste_banco' | 'outro' = 'outro'; if (h.registroId && h.horaAnterior !== undefined) { tipoAlteracao = 'edicao_registro'; } else if (h.tipoAjuste) { tipoAlteracao = 'ajuste_banco'; } // Calcular diferença em minutos (se for edição de registro) let diferencaMinutos: number | undefined = undefined; if (h.horaAnterior !== undefined && h.horaNova !== undefined) { const minutosAnterior = h.horaAnterior * 60 + (h.minutoAnterior || 0); const minutosNovo = h.horaNova * 60 + (h.minutoNova || 0); diferencaMinutos = minutosNovo - minutosAnterior; } return { ...h, tipoAlteracao, diferencaMinutos, gestor: gestor ? { nome: gestor.nome } : null, registro: registro ? { data: registro.data, tipo: registro.tipo, horaAnterior: `${String(h.horaAnterior || 0).padStart(2, '0')}:${String(h.minutoAnterior || 0).padStart(2, '0')}`, horaNova: `${String(h.horaNova || 0).padStart(2, '0')}:${String(h.minutoNova || 0).padStart(2, '0')}` } : null, dataFormatada: new Date(h.criadoEm).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }; }) ); return historicoComDetalhes; } }); /** * 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) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'ponto', acao: 'ver' }); 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 }; } }); // ========== QUERIES E MUTATIONS DO SISTEMA AVANÇADO DE BANCO DE HORAS ========== /** * Obtém banco de horas completo com todos os detalhes e ajustes */ export const obterBancoHorasCompleto = query({ args: { funcionarioId: v.id('funcionarios'), data: v.string() // YYYY-MM-DD }, returns: v.object({ bancoHoras: v.union( v.object({ _id: v.id('bancoHoras'), funcionarioId: v.id('funcionarios'), data: v.string(), cargaHorariaDiaria: v.number(), horasTrabalhadas: v.number(), saldoMinutos: v.number(), tipoDia: v.optional( v.union( v.literal('normal'), v.literal('atestado'), v.literal('licenca'), v.literal('ausencia'), v.literal('abonado'), v.literal('descontado') ) ), motivoAbono: v.optional(v.string()), ajustes: v.array( v.object({ _id: v.id('ajustesBancoHoras'), tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), valorMinutos: v.number(), motivoDescricao: v.optional(v.string()), motivoTipo: v.optional( v.union( v.literal('atestado'), v.literal('licenca'), v.literal('ausencia'), v.literal('manual') ) ) }) ), inconsistencias: v.array( v.object({ _id: v.id('inconsistenciasBancoHoras'), tipo: v.union( v.literal('ponto_com_atestado'), v.literal('ponto_com_licenca'), v.literal('ponto_com_ausencia'), v.literal('registro_duplicado'), v.literal('sequencia_invalida'), v.literal('saldo_inconsistente') ), descricao: v.string(), status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')) }) ) }), v.null() ) }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) 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 { bancoHoras: null }; } // Buscar ajustes const ajustes = bancoHoras.ajustesIds ? await Promise.all(bancoHoras.ajustesIds.map((id) => ctx.db.get(id))) : []; const ajustesFiltrados = ajustes.filter((a): a is NonNullable => a !== null); // Buscar inconsistências const inconsistencias = bancoHoras.inconsistenciasIds ? await Promise.all(bancoHoras.inconsistenciasIds.map((id) => ctx.db.get(id))) : []; const inconsistenciasFiltradas = inconsistencias.filter( (i): i is NonNullable => i !== null ); return { bancoHoras: { _id: bancoHoras._id, funcionarioId: bancoHoras.funcionarioId, data: bancoHoras.data, cargaHorariaDiaria: bancoHoras.cargaHorariaDiaria, horasTrabalhadas: bancoHoras.horasTrabalhadas, saldoMinutos: bancoHoras.saldoMinutos, tipoDia: bancoHoras.tipoDia, motivoAbono: bancoHoras.motivoAbono, ajustes: ajustesFiltrados.map((a) => ({ _id: a._id, tipo: a.tipo, valorMinutos: a.valorMinutos, motivoDescricao: a.motivoDescricao, motivoTipo: a.motivoTipo })), inconsistencias: inconsistenciasFiltradas.map((i) => ({ _id: i._id, tipo: i.tipo, descricao: i.descricao, status: i.status })) } }; } }); /** * Lista ajustes de banco de horas de um funcionário/período */ export const listarAjustesBancoHoras = query({ args: { funcionarioId: v.id('funcionarios'), dataInicio: v.optional(v.string()), // YYYY-MM-DD dataFim: v.optional(v.string()) // YYYY-MM-DD }, returns: v.array( v.object({ _id: v.id('ajustesBancoHoras'), tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), valorMinutos: v.number(), motivoTipo: v.optional( v.union( v.literal('atestado'), v.literal('licenca'), v.literal('ausencia'), v.literal('manual') ) ), motivoDescricao: v.optional(v.string()), dataAplicacao: v.string(), aplicado: v.boolean(), gestor: v.union( v.object({ nome: v.string() }), v.null() ) }) ), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) let query = ctx.db .query('ajustesBancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)); // Filtrar por período se fornecido if (args.dataInicio || args.dataFim) { query = query.filter((q) => { const data = q.field('dataAplicacao'); if (args.dataInicio && args.dataFim) { return q.and(q.gte(data, args.dataInicio), q.lte(data, args.dataFim)); } else if (args.dataInicio) { return q.gte(data, args.dataInicio); } else if (args.dataFim) { return q.lte(data, args.dataFim); } return true; }); } const ajustes = await query.order('desc').collect(); // Buscar informações dos gestores const ajustesComDetalhes = await Promise.all( ajustes.map(async (ajuste) => { const gestor = ajuste.gestorId ? await ctx.db.get(ajuste.gestorId) : null; return { _id: ajuste._id, tipo: ajuste.tipo, valorMinutos: ajuste.valorMinutos, motivoTipo: ajuste.motivoTipo, motivoDescricao: ajuste.motivoDescricao, dataAplicacao: ajuste.dataAplicacao, aplicado: ajuste.aplicado, gestor: gestor ? { nome: gestor.nome } : null }; }) ); return ajustesComDetalhes; } }); /** * Lista inconsistências detectadas */ export const listarInconsistenciasBancoHoras = query({ args: { funcionarioId: v.optional(v.id('funcionarios')), status: v.optional( v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')) ) }, returns: v.array( v.object({ _id: v.id('inconsistenciasBancoHoras'), funcionarioId: v.id('funcionarios'), tipo: v.union( v.literal('ponto_com_atestado'), v.literal('ponto_com_licenca'), v.literal('ponto_com_ausencia'), v.literal('registro_duplicado'), v.literal('sequencia_invalida'), v.literal('saldo_inconsistente') ), descricao: v.string(), dataDetectada: v.string(), dataInconsistencia: v.string(), status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')), funcionario: v.union( v.object({ nome: v.string(), matricula: v.optional(v.string()) }), v.null() ) }) ), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } let inconsistencias: Doc<'inconsistenciasBancoHoras'>[]; // Usar índice composto se ambos os filtros estiverem presentes if (args.funcionarioId && args.status) { const funcionarioId = args.funcionarioId; const status = args.status; inconsistencias = await ctx.db .query('inconsistenciasBancoHoras') .withIndex('by_funcionario_status', (q) => q.eq('funcionarioId', funcionarioId).eq('status', status) ) .order('desc') .collect(); } else if (args.funcionarioId) { // Filtrar apenas por funcionário const funcionarioId = args.funcionarioId; inconsistencias = await ctx.db .query('inconsistenciasBancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) .order('desc') .collect(); } else if (args.status) { // Filtrar apenas por status const status = args.status; inconsistencias = await ctx.db .query('inconsistenciasBancoHoras') .withIndex('by_status', (q) => q.eq('status', status)) .order('desc') .collect(); } else { // Sem filtros, usar full table scan e ordenar manualmente inconsistencias = await ctx.db.query('inconsistenciasBancoHoras').collect(); // Ordenar por data de detecção (mais recente primeiro) inconsistencias = inconsistencias.sort((a, b) => { const dataA = new Date(a.dataDetectada).getTime(); const dataB = new Date(b.dataDetectada).getTime(); return dataB - dataA; // Descendente }); } // Buscar informações dos funcionários const inconsistenciasComDetalhes = await Promise.all( inconsistencias.map(async (inconsistencia: Doc<'inconsistenciasBancoHoras'>) => { const funcionario = (await ctx.db.get( inconsistencia.funcionarioId )) as Doc<'funcionarios'> | null; return { _id: inconsistencia._id, funcionarioId: inconsistencia.funcionarioId, tipo: inconsistencia.tipo, descricao: inconsistencia.descricao, dataDetectada: inconsistencia.dataDetectada, dataInconsistencia: inconsistencia.dataInconsistencia, status: inconsistencia.status, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula } : null }; }) ); return inconsistenciasComDetalhes; } }); /** * Obtém configurações do sistema de banco de horas */ export const obterConfiguracaoBancoHoras = query({ args: {}, returns: v.union( v.object({ limiteSaldoPositivoMinutos: v.optional(v.number()), limiteSaldoNegativoMinutos: v.optional(v.number()), considerarAjustesAutomaticos: v.optional(v.boolean()), periodicidadeVerificacao: v.optional( v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')) ) }), v.null() ), handler: async (ctx) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.configurar) const config = await ctx.db.query('configuracaoBancoHoras').order('desc').first(); if (!config) { return null; } return { limiteSaldoPositivoMinutos: config.limiteSaldoPositivoMinutos, limiteSaldoNegativoMinutos: config.limiteSaldoNegativoMinutos, considerarAjustesAutomaticos: config.considerarAjustesAutomaticos, periodicidadeVerificacao: config.periodicidadeVerificacao }; } }); /** * Obtém alertas configurados */ export const obterAlertasConfigurados = query({ args: {}, returns: v.array( v.object({ _id: v.id('alertasBancoHoras'), tipoAlerta: v.union( v.literal('saldo_negativo'), v.literal('saldo_negativo_critico'), v.literal('inconsistencia_detectada'), v.literal('dias_sem_registro'), v.literal('limite_saldo_excedido') ), periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')), enviarEmail: v.boolean(), enviarChat: v.boolean(), destinatariosEmail: v.optional(v.array(v.id('usuarios'))), destinatariosChat: v.optional(v.array(v.id('usuarios'))), threshold: v.optional(v.number()), limiteMinutos: v.optional(v.number()), ativo: v.boolean() }) ), handler: async (ctx) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.configurar) // Retornar todos os alertas (ativos e inativos) para permitir edição const alertas = await ctx.db.query('alertasBancoHoras').collect(); return alertas.map((a) => ({ _id: a._id, tipoAlerta: a.tipoAlerta, periodicidade: a.periodicidade, enviarEmail: a.enviarEmail, enviarChat: a.enviarChat, destinatariosEmail: a.destinatariosEmail || [], destinatariosChat: a.destinatariosChat || [], threshold: a.threshold, limiteMinutos: a.limiteMinutos, ativo: a.ativo })); } }); /** * Verifica inconsistências para um funcionário/período */ export const verificarInconsistencias = query({ args: { funcionarioId: v.id('funcionarios'), dataInicio: v.optional(v.string()), dataFim: v.optional(v.string()) }, returns: v.array( v.object({ _id: v.id('inconsistenciasBancoHoras'), tipo: v.union( v.literal('ponto_com_atestado'), v.literal('ponto_com_licenca'), v.literal('ponto_com_ausencia'), v.literal('registro_duplicado'), v.literal('sequencia_invalida'), v.literal('saldo_inconsistente') ), descricao: v.string(), dataDetectada: v.string(), status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')) }) ), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ver' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.ver) let query = ctx.db .query('inconsistenciasBancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)); // Filtrar por período se fornecido if (args.dataInicio || args.dataFim) { query = query.filter((q) => { const data = q.field('dataDetectada'); if (args.dataInicio && args.dataFim) { return q.and(q.gte(data, args.dataInicio), q.lte(data, args.dataFim)); } else if (args.dataInicio) { return q.gte(data, args.dataInicio); } else if (args.dataFim) { return q.lte(data, args.dataFim); } return true; }); } const inconsistencias = await query.order('desc').collect(); return inconsistencias.map((i) => ({ _id: i._id, tipo: i.tipo, descricao: i.descricao, dataDetectada: i.dataDetectada, status: i.status })); } }); /** * Cria ajuste manual de banco de horas (abonar/descontar) */ export const criarAjusteBancoHoras = mutation({ args: { funcionarioId: v.id('funcionarios'), tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), valorHoras: v.number(), valorMinutos: v.number(), dataAplicacao: v.string(), // YYYY-MM-DD motivoDescricao: v.string(), observacoes: v.optional(v.string()) }, returns: v.object({ ajusteId: v.id('ajustesBancoHoras'), success: v.boolean() }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ajustar' }); 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 ajuste de banco de horas para este funcionário' ); } // Calcular valor total em minutos const valorTotalMinutos = args.valorHoras * 60 + args.valorMinutos; const valorFinal = args.tipo === 'descontar' ? -valorTotalMinutos : valorTotalMinutos; // Criar ajuste const ajusteId = await ctx.db.insert('ajustesBancoHoras', { funcionarioId: args.funcionarioId, tipo: args.tipo, motivoTipo: 'manual', motivoDescricao: args.motivoDescricao, valorMinutos: valorFinal, dataAplicacao: args.dataAplicacao, gestorId: usuario._id, observacoes: args.observacoes, aplicado: false, // Será aplicado no próximo recálculo criadoEm: Date.now() }); // Aplicar ajuste imediatamente const bancoHorasAtual = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', args.funcionarioId).eq('data', args.dataAplicacao) ) .first(); if (bancoHorasAtual) { // Atualizar saldo do dia await ctx.db.patch(bancoHorasAtual._id, { saldoMinutos: bancoHorasAtual.saldoMinutos + valorFinal, ajustesIds: [...(bancoHorasAtual.ajustesIds || []), ajusteId] }); } else { // Criar novo registro de banco de horas para o dia const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (config) { const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); await ctx.db.insert('bancoHoras', { funcionarioId: args.funcionarioId, data: args.dataAplicacao, cargaHorariaDiaria, horasTrabalhadas: 0, saldoMinutos: valorFinal, registrosPontoIds: [], ajustesIds: [ajusteId], tipoDia: args.tipo === 'abonar' ? 'abonado' : 'descontado', motivoAbono: args.motivoDescricao, calculadoEm: Date.now() }); } } // Marcar ajuste como aplicado await ctx.db.patch(ajusteId, { aplicado: true, aplicadoEm: Date.now() }); // Recalcular banco de horas mensal const mes = args.dataAplicacao.substring(0, 7); // Verificar se estamos aplicando ajuste em um mês passado const hoje = new Date(); const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; const estaAplicandoEmMesPassado = mes < mesAtual; // Se estamos aplicando em um mês passado, recalcular em cascata para atualizar meses seguintes await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAplicandoEmMesPassado); return { ajusteId, success: true }; } }); /** * Resolve uma inconsistência */ export const resolverInconsistencia = mutation({ args: { inconsistenciaId: v.id('inconsistenciasBancoHoras'), resolucao: v.string() }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'ajustar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const inconsistencia = await ctx.db.get(args.inconsistenciaId); if (!inconsistencia) { throw new Error('Inconsistência não encontrada'); } // Verificar se é gestor do funcionário const isGestor = await verificarGestorDoFuncionario( ctx, usuario._id, inconsistencia.funcionarioId ); if (!isGestor) { throw new Error('Você não tem permissão para resolver esta inconsistência'); } // Atualizar inconsistência await ctx.db.patch(args.inconsistenciaId, { status: 'resolvida', resolucao: args.resolucao, resolvidoPor: usuario._id, resolvidoEm: Date.now() }); return { success: true }; } }); /** * Atualiza configurações gerais do banco de horas */ export const atualizarConfiguracaoBancoHoras = mutation({ args: { limiteSaldoPositivoMinutos: v.optional(v.number()), limiteSaldoNegativoMinutos: v.optional(v.number()), considerarAjustesAutomaticos: v.optional(v.boolean()), periodicidadeVerificacao: v.optional( v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')) ) }, returns: v.object({ success: v.boolean(), configId: v.id('configuracaoBancoHoras') }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.configurar) // Buscar configuração existente ou criar nova const configExistente = await ctx.db.query('configuracaoBancoHoras').order('desc').first(); if (configExistente) { await ctx.db.patch(configExistente._id, { limiteSaldoPositivoMinutos: args.limiteSaldoPositivoMinutos, limiteSaldoNegativoMinutos: args.limiteSaldoNegativoMinutos, considerarAjustesAutomaticos: args.considerarAjustesAutomaticos, periodicidadeVerificacao: args.periodicidadeVerificacao, atualizadoPor: usuario._id, atualizadoEm: Date.now() }); return { success: true, configId: configExistente._id }; } else { const configId = await ctx.db.insert('configuracaoBancoHoras', { limiteSaldoPositivoMinutos: args.limiteSaldoPositivoMinutos, limiteSaldoNegativoMinutos: args.limiteSaldoNegativoMinutos, considerarAjustesAutomaticos: args.considerarAjustesAutomaticos ?? true, periodicidadeVerificacao: args.periodicidadeVerificacao, atualizadoPor: usuario._id, atualizadoEm: Date.now() }); return { success: true, configId }; } } }); /** * Atualiza configuração de alerta específico */ export const criarAlertaBancoHoras = mutation({ args: { tipoAlerta: v.union( v.literal('saldo_negativo'), v.literal('saldo_negativo_critico'), v.literal('inconsistencia_detectada'), v.literal('dias_sem_registro'), v.literal('limite_saldo_excedido') ), periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')), enviarEmail: v.boolean(), enviarChat: v.boolean(), destinatariosEmail: v.optional(v.array(v.id('usuarios'))), destinatariosChat: v.optional(v.array(v.id('usuarios'))), threshold: v.optional(v.number()), limiteMinutos: v.optional(v.number()), ativo: v.boolean() }, returns: v.object({ success: v.boolean(), alertaId: v.id('alertasBancoHoras') }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.configurar) // Verificar se já existe alerta do mesmo tipo const alertaExistente = await ctx.db .query('alertasBancoHoras') .withIndex('by_tipo', (q) => q.eq('tipoAlerta', args.tipoAlerta)) .first(); if (alertaExistente) { throw new Error('Já existe um alerta configurado para este tipo'); } const alertaId = await ctx.db.insert('alertasBancoHoras', { tipoAlerta: args.tipoAlerta, periodicidade: args.periodicidade, enviarEmail: args.enviarEmail, enviarChat: args.enviarChat, destinatariosEmail: args.destinatariosEmail || [], destinatariosChat: args.destinatariosChat || [], threshold: args.threshold, limiteMinutos: args.limiteMinutos, ativo: args.ativo, criadoPor: usuario._id, criadoEm: Date.now() }); return { success: true, alertaId }; } }); export const atualizarConfiguracaoAlerta = mutation({ args: { alertaId: v.id('alertasBancoHoras'), periodicidade: v.optional( v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')) ), enviarEmail: v.optional(v.boolean()), enviarChat: v.optional(v.boolean()), destinatariosEmail: v.optional(v.array(v.id('usuarios'))), destinatariosChat: v.optional(v.array(v.id('usuarios'))), threshold: v.optional(v.number()), limiteMinutos: v.optional(v.number()), ativo: v.optional(v.boolean()) }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Permissão já verificada acima (banco_horas.configurar) const alerta = await ctx.db.get(args.alertaId); if (!alerta) { throw new Error('Alerta não encontrado'); } await ctx.db.patch(args.alertaId, { periodicidade: args.periodicidade ?? alerta.periodicidade, enviarEmail: args.enviarEmail ?? alerta.enviarEmail, enviarChat: args.enviarChat ?? alerta.enviarChat, destinatariosEmail: args.destinatariosEmail !== undefined ? args.destinatariosEmail : alerta.destinatariosEmail, destinatariosChat: args.destinatariosChat !== undefined ? args.destinatariosChat : alerta.destinatariosChat, threshold: args.threshold, limiteMinutos: args.limiteMinutos, ativo: args.ativo ?? alerta.ativo, atualizadoPor: usuario._id, atualizadoEm: Date.now() }); return { success: true }; } }); /** * Recalcula banco de horas para um funcionário/período */ export const recalcularBancoHoras = mutation({ args: { funcionarioId: v.id('funcionarios'), dataInicio: v.string(), // YYYY-MM-DD dataFim: v.string() // YYYY-MM-DD }, returns: v.object({ success: v.boolean(), diasRecalculados: v.number() }), handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); 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 recalcular banco de horas deste funcionário'); } // Buscar 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'); } // Gerar todas as datas do período const dataInicioObj = new Date(args.dataInicio); const dataFimObj = new Date(args.dataFim); const datas: string[] = []; const dataAtual = new Date(dataInicioObj); while (dataAtual <= dataFimObj) { const ano = dataAtual.getFullYear(); const mes = String(dataAtual.getMonth() + 1).padStart(2, '0'); const dia = String(dataAtual.getDate()).padStart(2, '0'); datas.push(`${ano}-${mes}-${dia}`); dataAtual.setDate(dataAtual.getDate() + 1); } // Recalcular para cada data let diasRecalculados = 0; for (const data of datas) { await atualizarBancoHoras(ctx, args.funcionarioId, data, { horarioEntrada: config.horarioEntrada, horarioSaidaAlmoco: config.horarioSaidaAlmoco, horarioRetornoAlmoco: config.horarioRetornoAlmoco, horarioSaida: config.horarioSaida }); diasRecalculados++; } return { success: true, diasRecalculados }; } }); /** * Mutation interna para recalcular banco de horas de uma data específica */ export const recalcularBancoHorasData = internalMutation({ args: { funcionarioId: v.id('funcionarios'), data: v.string() // YYYY-MM-DD }, returns: v.null(), handler: async (ctx, args) => { // Buscar configuração de ponto const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (!config) { return null; } // Recalcular banco de horas para a data await atualizarBancoHoras(ctx, args.funcionarioId, args.data, { horarioEntrada: config.horarioEntrada, horarioSaidaAlmoco: config.horarioSaidaAlmoco, horarioRetornoAlmoco: config.horarioRetornoAlmoco, horarioSaida: config.horarioSaida }); return null; } }); // ========== SISTEMA DE ALERTAS DE BANCO DE HORAS ========== /** * Helper: Encontrar gestor do funcionário */ async function encontrarGestorDoFuncionarioParaAlerta( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'> ): Promise | null> { const funcionario = await ctx.db.get(funcionarioId); if (!funcionario || !funcionario.gestorId) { return null; } return funcionario.gestorId; } /** * Helper: Enviar alerta via chat */ async function enviarAlertaChat( ctx: MutationCtx, gestorId: Id<'usuarios'>, titulo: string, mensagem: string ): Promise { // Criar notificação no sistema await ctx.db.insert('notificacoes', { usuarioId: gestorId, tipo: 'nova_mensagem', titulo, descricao: mensagem, lida: false, criadaEm: Date.now() }); } /** * Helper: Enviar alerta via email */ async function enviarAlertaEmail( ctx: MutationCtx, gestorId: Id<'usuarios'>, titulo: string, mensagem: string ): Promise { const gestor = await ctx.db.get(gestorId); if (!gestor || !gestor.email) { return; } // Obter URL do sistema let urlSistema = process.env.SITE_URL || 'http://localhost:5173'; if (!urlSistema.match(/^https?:\/\//i)) { urlSistema = `http://${urlSistema}`; } // Enviar email usando template await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { destinatario: gestor.email, destinatarioId: gestorId, templateCodigo: 'banco_horas_alerta', variaveis: { gestorNome: gestor.nome, titulo, mensagem, urlSistema }, enviadoPor: gestorId }); } /** * Detecta e envia alertas de banco de horas para um funcionário */ export const detectarEEnviarAlertasBancoHoras = internalMutation({ args: { funcionarioId: v.id('funcionarios') }, returns: v.object({ alertasEnviados: v.number() }), handler: async (ctx, args) => { // Buscar configurações de alertas ativas const alertasConfigurados = await ctx.db .query('alertasBancoHoras') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .collect(); if (alertasConfigurados.length === 0) { return { alertasEnviados: 0 }; } // Buscar gestor do funcionário const gestorId = await encontrarGestorDoFuncionarioParaAlerta(ctx, args.funcionarioId); if (!gestorId) { return { alertasEnviados: 0 }; } // Buscar banco de horas mensal atual const hoje = new Date(); const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; const bancoMensal = await ctx.db .query('bancoHorasMensal') .withIndex('by_funcionario_mes', (q) => q.eq('funcionarioId', args.funcionarioId).eq('mes', mesAtual) ) .first(); // Buscar inconsistências pendentes const inconsistenciasPendentes = await ctx.db .query('inconsistenciasBancoHoras') .withIndex('by_funcionario_status', (q) => q.eq('funcionarioId', args.funcionarioId).eq('status', 'pendente') ) .collect(); let alertasEnviados = 0; // Verificar cada tipo de alerta configurado for (const alertaConfig of alertasConfigurados) { let deveEnviar = false; let titulo = ''; let mensagem = ''; switch (alertaConfig.tipoAlerta) { case 'saldo_negativo': if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) { const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; if ( !alertaConfig.limiteMinutos || Math.abs(bancoMensal.saldoFinalMinutos) >= alertaConfig.limiteMinutos ) { deveEnviar = true; titulo = '⚠️ Alerta: Saldo Negativo de Banco de Horas'; mensagem = `O funcionário possui saldo negativo acumulado de ${Math.floor(horasNegativas)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min.`; } } break; case 'saldo_negativo_critico': if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) { const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; if (horasNegativas >= 8) { deveEnviar = true; titulo = '🚨 Alerta Crítico: Saldo Negativo Crítico de Banco de Horas'; mensagem = `O funcionário possui saldo negativo crítico acumulado de ${Math.floor(horasNegativas)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min. Ação imediata necessária.`; } } break; case 'inconsistencia_detectada': if (inconsistenciasPendentes.length > 0) { deveEnviar = true; titulo = '⚠️ Alerta: Inconsistências Detectadas no Banco de Horas'; mensagem = `Foram detectadas ${inconsistenciasPendentes.length} inconsistência(s) no banco de horas do funcionário que precisam ser resolvidas.`; } break; case 'dias_sem_registro': { // Verificar últimos 7 dias const ultimos7Dias: string[] = []; for (let i = 0; i < 7; i++) { const data = new Date(); data.setDate(data.getDate() - i); ultimos7Dias.push(data.toISOString().split('T')[0]!); } const registrosRecentes = await ctx.db .query('bancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) .filter((q) => { const data = q.field('data'); return q.or(...ultimos7Dias.map((dia) => q.eq(data, dia))); }) .collect(); const diasComRegistro = new Set(registrosRecentes.map((r) => r.data)); const diasSemRegistro = ultimos7Dias.filter((dia) => !diasComRegistro.has(dia)); if (diasSemRegistro.length >= 3) { deveEnviar = true; titulo = '⚠️ Alerta: Múltiplos Dias Sem Registro de Ponto'; mensagem = `O funcionário não possui registro de ponto em ${diasSemRegistro.length} dos últimos 7 dias.`; } break; } case 'limite_saldo_excedido': if (bancoMensal) { const config = await ctx.db.query('configuracaoBancoHoras').order('desc').first(); if (config) { if ( config.limiteSaldoPositivoMinutos && bancoMensal.saldoFinalMinutos > config.limiteSaldoPositivoMinutos ) { deveEnviar = true; titulo = '⚠️ Alerta: Limite de Saldo Positivo Excedido'; mensagem = `O funcionário excedeu o limite de saldo positivo configurado.`; } else if ( config.limiteSaldoNegativoMinutos && Math.abs(bancoMensal.saldoFinalMinutos) > config.limiteSaldoNegativoMinutos ) { deveEnviar = true; titulo = '⚠️ Alerta: Limite de Saldo Negativo Excedido'; mensagem = `O funcionário excedeu o limite de saldo negativo configurado.`; } } } break; } if (deveEnviar && (alertaConfig.enviarChat || alertaConfig.enviarEmail)) { // Determinar destinatários: usar específicos se configurados, senão usar gestor padrão const destinatariosChat = alertaConfig.destinatariosChat && alertaConfig.destinatariosChat.length > 0 ? alertaConfig.destinatariosChat : gestorId ? [gestorId] : []; const destinatariosEmail = alertaConfig.destinatariosEmail && alertaConfig.destinatariosEmail.length > 0 ? alertaConfig.destinatariosEmail : gestorId ? [gestorId] : []; // Enviar para destinatários de chat if (alertaConfig.enviarChat && destinatariosChat.length > 0) { for (const destinatarioId of destinatariosChat) { await enviarAlertaChat(ctx, destinatarioId, titulo, mensagem); } } // Enviar para destinatários de email if (alertaConfig.enviarEmail && destinatariosEmail.length > 0) { for (const destinatarioId of destinatariosEmail) { await enviarAlertaEmail(ctx, destinatarioId, titulo, mensagem); } } if (destinatariosChat.length > 0 || destinatariosEmail.length > 0) { alertasEnviados++; } } } return { alertasEnviados }; } }); /** * Action interna para processar alertas de banco de horas (chamada por cron) */ export const processarAlertasBancoHoras = internalMutation({ args: {}, returns: v.object({ funcionariosProcessados: v.number(), alertasEnviados: v.number() }), handler: async (ctx) => { // Buscar todos os funcionários ativos const funcionarios = await ctx.db .query('funcionarios') .filter((q) => q.eq(q.field('desligamentoData'), undefined)) .collect(); let totalAlertasEnviados = 0; for (const funcionario of funcionarios) { const resultado = await ctx.runMutation(internal.pontos.detectarEEnviarAlertasBancoHoras, { funcionarioId: funcionario._id }); totalAlertasEnviados += resultado.alertasEnviados; } return { funcionariosProcessados: funcionarios.length, alertasEnviados: totalAlertasEnviados }; } }); /** * Inicializa alertas padrão do sistema (chamada uma vez) */ export const inicializarAlertasPadrao = mutation({ args: {}, returns: v.object({ success: v.boolean(), alertasCriados: v.number() }), handler: async (ctx) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'banco_horas', acao: 'configurar' }); const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // TODO: Verificar permissão de TI // Verificar se já existem alertas const alertasExistentes = await ctx.db.query('alertasBancoHoras').collect(); if (alertasExistentes.length > 0) { return { success: true, alertasCriados: 0 }; } // Criar alertas padrão const tiposAlerta: Array<{ tipoAlerta: | 'saldo_negativo' | 'saldo_negativo_critico' | 'inconsistencia_detectada' | 'dias_sem_registro' | 'limite_saldo_excedido'; periodicidade: 'diario' | 'semanal' | 'mensal'; enviarEmail: boolean; enviarChat: boolean; limiteMinutos?: number; }> = [ { tipoAlerta: 'saldo_negativo', periodicidade: 'diario', enviarEmail: true, enviarChat: true, limiteMinutos: 60 // 1 hora }, { tipoAlerta: 'saldo_negativo_critico', periodicidade: 'diario', enviarEmail: true, enviarChat: true }, { tipoAlerta: 'inconsistencia_detectada', periodicidade: 'diario', enviarEmail: true, enviarChat: true }, { tipoAlerta: 'dias_sem_registro', periodicidade: 'semanal', enviarEmail: true, enviarChat: true }, { tipoAlerta: 'limite_saldo_excedido', periodicidade: 'diario', enviarEmail: true, enviarChat: true } ]; let alertasCriados = 0; for (const tipoAlerta of tiposAlerta) { await ctx.db.insert('alertasBancoHoras', { ...tipoAlerta, ativo: true, criadoPor: usuario._id, criadoEm: Date.now() }); alertasCriados++; } return { success: true, alertasCriados }; } });