import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; /** * Gera URL para upload de imagem do ponto */ export const generateUploadUrl = mutation({ args: {}, handler: async (ctx) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } return await ctx.storage.generateUploadUrl(); }, }); /** * Calcula se o registro está dentro do prazo baseado na configuração * Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true) */ function calcularStatusPonto( hora: number, minuto: number, horarioConfigurado: string, toleranciaMinutos: number ): boolean { // Se tolerância for 0, desconsiderar atrasos (qualquer registro é válido) if (toleranciaMinutos === 0) { return true; } const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number); const totalMinutosRegistro = hora * 60 + minuto; const totalMinutosConfigurado = horaConfig * 60 + minutoConfig; const diferenca = totalMinutosRegistro - totalMinutosConfigurado; return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos; } /** * Determina o tipo de registro baseado na sequência lógica */ async function determinarTipoRegistro( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string ): Promise<'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'> { const registrosHoje = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .order('desc') .collect(); if (registrosHoje.length === 0) { return 'entrada'; } const ultimoRegistro = registrosHoje[0]; switch (ultimoRegistro.tipo) { case 'entrada': return 'saida_almoco'; case 'saida_almoco': return 'retorno_almoco'; case 'retorno_almoco': return 'saida'; case 'saida': // Se já saiu, próximo registro é entrada (novo dia) return 'entrada'; default: return 'entrada'; } } /** * Registra um ponto (entrada, saída, etc.) */ export const registrarPonto = mutation({ args: { imagemId: v.optional(v.id('_storage')), informacoesDispositivo: v.optional( v.object({ ipAddress: v.optional(v.string()), ipPublico: v.optional(v.string()), ipLocal: v.optional(v.string()), userAgent: v.optional(v.string()), browser: v.optional(v.string()), browserVersion: v.optional(v.string()), engine: v.optional(v.string()), sistemaOperacional: v.optional(v.string()), osVersion: v.optional(v.string()), arquitetura: v.optional(v.string()), plataforma: v.optional(v.string()), latitude: v.optional(v.number()), longitude: v.optional(v.number()), precisao: v.optional(v.number()), endereco: v.optional(v.string()), cidade: v.optional(v.string()), estado: v.optional(v.string()), pais: v.optional(v.string()), timezone: v.optional(v.string()), deviceType: v.optional(v.string()), deviceModel: v.optional(v.string()), screenResolution: v.optional(v.string()), coresTela: v.optional(v.number()), idioma: v.optional(v.string()), isMobile: v.optional(v.boolean()), isTablet: v.optional(v.boolean()), isDesktop: v.optional(v.boolean()), connectionType: v.optional(v.string()), memoryInfo: v.optional(v.string()), }) ), timestamp: v.number(), sincronizadoComServidor: v.boolean(), justificativa: v.optional(v.string()), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } if (!usuario.funcionarioId) { throw new Error('Usuário não possui funcionário associado'); } // Verificar se funcionário está ativo const funcionario = await ctx.db.get(usuario.funcionarioId); if (!funcionario) { throw new Error('Funcionário não encontrado'); } // Obter configuração de ponto const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (!config) { throw new Error('Configuração de ponto não encontrada'); } // Obter configuração de ponto para GMT offset (buscar configuração ativa) const configPonto = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); // Converter timestamp para data/hora com ajuste de GMT const gmtOffset = configPonto?.gmtOffset ?? 0; const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000); const dataObj = new Date(timestampAjustado); const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD const hora = dataObj.getUTCHours(); const minuto = dataObj.getUTCMinutes(); const segundo = dataObj.getUTCSeconds(); // Verificar se já existe registro no mesmo minuto const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined const registrosMinuto = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .collect(); const registroDuplicado = registrosMinuto.find( (r) => r.hora === hora && r.minuto === minuto ); if (registroDuplicado) { throw new Error('Já existe um registro neste minuto'); } // Determinar tipo de registro const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data); // Calcular horário esperado e tolerância let horarioConfigurado = ''; switch (tipo) { case 'entrada': horarioConfigurado = config.horarioEntrada; break; case 'saida_almoco': horarioConfigurado = config.horarioSaidaAlmoco; break; case 'retorno_almoco': horarioConfigurado = config.horarioRetornoAlmoco; break; case 'saida': horarioConfigurado = config.horarioSaida; break; } const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos); // Criar registro const registroId = await ctx.db.insert('registrosPonto', { funcionarioId: usuario.funcionarioId, tipo, data, hora, minuto, segundo, timestamp: args.timestamp, imagemId: args.imagemId, sincronizadoComServidor: args.sincronizadoComServidor, toleranciaMinutos: config.toleranciaMinutos, dentroDoPrazo, justificativa: args.justificativa, ipAddress: args.informacoesDispositivo?.ipAddress, ipPublico: args.informacoesDispositivo?.ipPublico, ipLocal: args.informacoesDispositivo?.ipLocal, userAgent: args.informacoesDispositivo?.userAgent, browser: args.informacoesDispositivo?.browser, browserVersion: args.informacoesDispositivo?.browserVersion, engine: args.informacoesDispositivo?.engine, sistemaOperacional: args.informacoesDispositivo?.sistemaOperacional, osVersion: args.informacoesDispositivo?.osVersion, arquitetura: args.informacoesDispositivo?.arquitetura, plataforma: args.informacoesDispositivo?.plataforma, latitude: args.informacoesDispositivo?.latitude, longitude: args.informacoesDispositivo?.longitude, precisao: args.informacoesDispositivo?.precisao, endereco: args.informacoesDispositivo?.endereco, cidade: args.informacoesDispositivo?.cidade, estado: args.informacoesDispositivo?.estado, pais: args.informacoesDispositivo?.pais, timezone: args.informacoesDispositivo?.timezone, deviceType: args.informacoesDispositivo?.deviceType, deviceModel: args.informacoesDispositivo?.deviceModel, screenResolution: args.informacoesDispositivo?.screenResolution, coresTela: args.informacoesDispositivo?.coresTela, idioma: args.informacoesDispositivo?.idioma, isMobile: args.informacoesDispositivo?.isMobile, isTablet: args.informacoesDispositivo?.isTablet, isDesktop: args.informacoesDispositivo?.isDesktop, connectionType: args.informacoesDispositivo?.connectionType, memoryInfo: args.informacoesDispositivo?.memoryInfo, criadoEm: Date.now(), }); // Atualizar banco de horas após registrar await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config); return { registroId, tipo, dentroDoPrazo }; }, }); /** * Lista registros do dia atual do funcionário */ export const listarRegistrosDia = query({ args: { data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario || !usuario.funcionarioId) { return []; } const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined const data = args.data || new Date().toISOString().split('T')[0]!; const registros = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .order('asc') .collect(); return registros; }, }); /** * Lista registros por período (para RH) */ export const listarRegistrosPeriodo = query({ args: { funcionarioId: v.optional(v.id('funcionarios')), dataInicio: v.string(), // YYYY-MM-DD dataFim: v.string(), // YYYY-MM-DD }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Verificar permissão (RH ou TI) // Por enquanto, permitir se tiver funcionarioId ou for admin // TODO: Implementar verificação de permissão adequada const dataFim = new Date(args.dataFim); dataFim.setHours(23, 59, 59, 999); const registros = await ctx.db .query('registrosPonto') .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) .collect(); // Filtrar por funcionário se especificado let registrosFiltrados = registros; if (args.funcionarioId) { registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId); } // Buscar informações dos funcionários const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId)); const funcionarios = await Promise.all( Array.from(funcionariosIds).map((id) => ctx.db.get(id)) ); return registrosFiltrados.map((registro) => { const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId); return { ...registro, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula, descricaoCargo: funcionario.descricaoCargo, } : null, }; }); }, }); /** * Obtém estatísticas de pontos (para gráficos) */ export const obterEstatisticas = query({ args: { dataInicio: v.string(), // YYYY-MM-DD dataFim: v.string(), // YYYY-MM-DD }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // TODO: Verificar permissão (RH ou TI) const registros = await ctx.db .query('registrosPonto') .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) .collect(); const totalRegistros = registros.length; const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length; const foraDoPrazo = totalRegistros - dentroDoPrazo; // Agrupar por funcionário const funcionariosUnicos = new Set(registros.map((r) => r.funcionarioId)); const totalFuncionarios = funcionariosUnicos.size; // Funcionários com registros dentro do prazo const funcionariosDentroPrazo = new Set( registros.filter((r) => r.dentroDoPrazo).map((r) => r.funcionarioId) ).size; // Funcionários com registros fora do prazo const funcionariosForaPrazo = totalFuncionarios - funcionariosDentroPrazo; return { totalRegistros, dentroDoPrazo, foraDoPrazo, totalFuncionarios, funcionariosDentroPrazo, funcionariosForaPrazo, }; }, }); /** * Obtém um registro específico (para comprovante) */ export const obterRegistro = query({ args: { registroId: v.id('registrosPonto'), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const registro = await ctx.db.get(args.registroId); if (!registro) { throw new Error('Registro não encontrado'); } // Verificar se o usuário tem permissão (próprio registro ou RH/TI) if (registro.funcionarioId !== usuario.funcionarioId) { // TODO: Verificar se é RH ou TI // Por enquanto, permitir } const funcionario = await ctx.db.get(registro.funcionarioId); let simbolo = null; if (funcionario) { simbolo = await ctx.db.get(funcionario.simboloId); } // Obter URL da imagem se existir let imagemUrl = null; if (registro.imagemId) { imagemUrl = await ctx.storage.getUrl(registro.imagemId); } return { ...registro, imagemUrl, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula, descricaoCargo: funcionario.descricaoCargo, simbolo: simbolo ? { nome: simbolo.nome, tipo: simbolo.tipo, } : null, } : null, }; }, }); /** * Calcula carga horária diária esperada em minutos */ function calcularCargaHorariaDiaria(config: { horarioEntrada: string; horarioSaidaAlmoco: string; horarioRetornoAlmoco: string; horarioSaida: string; }): number { const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number); const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number); const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number); const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number); const minutosEntrada = horaEntrada * 60 + minutoEntrada; const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco; const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco; const minutosSaida = horaSaida * 60 + minutoSaida; // Calcular horas trabalhadas: (saída almoço - entrada) + (saída - retorno almoço) const horasManha = minutosSaidaAlmoco - minutosEntrada; const horasTarde = minutosSaida - minutosRetornoAlmoco; return horasManha + horasTarde; } /** * Calcula horas trabalhadas do dia baseado nos registros */ function calcularHorasTrabalhadas(registros: Array<{ tipo: string; hora: number; minuto: number; }>): number { // Ordenar registros por timestamp const registrosOrdenados = [...registros].sort((a, b) => { const minutosA = a.hora * 60 + a.minuto; const minutosB = b.hora * 60 + b.minuto; return minutosA - minutosB; }); let horasTrabalhadas = 0; // Procurar entrada e saída const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); const saida = registrosOrdenados.find((r) => r.tipo === 'saida'); if (entrada && saida) { const minutosEntrada = entrada.hora * 60 + entrada.minuto; const minutosSaida = saida.hora * 60 + saida.minuto; // Procurar saída e retorno do almoço const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco'); const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco'); if (saidaAlmoco && retornoAlmoco) { // Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço) const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto; const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto; const horasManha = minutosSaidaAlmoco - minutosEntrada; const horasTarde = minutosSaida - minutosRetornoAlmoco; horasTrabalhadas = horasManha + horasTarde; } else { // Sem intervalo de almoço registrado: saída - entrada horasTrabalhadas = minutosSaida - minutosEntrada; } } return horasTrabalhadas; } /** * Atualiza ou cria registro de banco de horas para o dia */ async function atualizarBancoHoras( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, data: string, config: { horarioEntrada: string; horarioSaidaAlmoco: string; horarioRetornoAlmoco: string; horarioSaida: string; } ): Promise { // Buscar todos os registros do dia const registrosDoDia = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .collect(); // Calcular carga horária esperada const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); // Calcular horas trabalhadas const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia); // Calcular saldo (positivo = horas extras, negativo = déficit) const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; // Buscar banco de horas existente const bancoHorasExistente = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .first(); const registrosPontoIds = registrosDoDia.map((r) => r._id); if (bancoHorasExistente) { // Atualizar existente await ctx.db.patch(bancoHorasExistente._id, { cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, registrosPontoIds, calculadoEm: Date.now(), }); } else { // Criar novo await ctx.db.insert('bancoHoras', { funcionarioId, data, cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, registrosPontoIds, calculadoEm: Date.now(), }); } } /** * Obtém histórico e saldo do dia */ export const obterHistoricoESaldoDia = query({ args: { funcionarioId: v.id('funcionarios'), data: v.string(), // YYYY-MM-DD }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario || !usuario.funcionarioId) { throw new Error('Usuário não autenticado'); } // Verificar se é o próprio funcionário ou tem permissão if (usuario.funcionarioId !== args.funcionarioId) { // TODO: Verificar permissão de RH } // Buscar registros do dia const registros = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', args.funcionarioId).eq('data', args.data) ) .order('asc') .collect(); // Buscar configuração de ponto const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (!config) { return { registros: [], cargaHorariaDiaria: 0, horasTrabalhadas: 0, saldoMinutos: 0, }; } // Calcular valores const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); const horasTrabalhadas = calcularHorasTrabalhadas(registros); const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; return { registros, cargaHorariaDiaria, horasTrabalhadas, saldoMinutos, }; }, }); /** * Obtém banco de horas acumulado do funcionário */ export const obterBancoHorasFuncionario = query({ args: { funcionarioId: v.id('funcionarios'), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Verificar se é o próprio funcionário ou tem permissão if (usuario.funcionarioId !== args.funcionarioId) { // TODO: Verificar permissão de RH } // Buscar todos os registros de banco de horas do funcionário const bancosHoras = await ctx.db .query('bancoHoras') .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) .order('desc') .collect(); // Calcular saldo acumulado const saldoAcumuladoMinutos = bancosHoras.reduce((acc, bh) => acc + bh.saldoMinutos, 0); return { bancosHoras, saldoAcumuladoMinutos, totalDias: bancosHoras.length, }; }, });