import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; /** * Gera URL para upload de imagem do ponto */ export const generateUploadUrl = mutation({ args: {}, handler: async (ctx) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } return await ctx.storage.generateUploadUrl(); }, }); interface InformacoesDispositivo { ipAddress?: string; ipPublico?: string; ipLocal?: string; userAgent?: string; browser?: string; browserVersion?: string; engine?: string; sistemaOperacional?: string; osVersion?: string; arquitetura?: string; plataforma?: string; latitude?: number; longitude?: number; precisao?: number; endereco?: string; cidade?: string; estado?: string; pais?: string; timezone?: string; deviceType?: string; deviceModel?: string; screenResolution?: string; coresTela?: number; idioma?: string; isMobile?: boolean; isTablet?: boolean; isDesktop?: boolean; connectionType?: string; memoryInfo?: string; } /** * Calcula se o registro está dentro do prazo baseado na configuração */ function calcularStatusPonto( hora: number, minuto: number, horarioConfigurado: string, toleranciaMinutos: number ): boolean { const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number); const totalMinutosRegistro = hora * 60 + minuto; const totalMinutosConfigurado = horaConfig * 60 + minutoConfig; const diferenca = totalMinutosRegistro - totalMinutosConfigurado; return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos; } /** * Determina o tipo de registro baseado na sequência lógica */ async function determinarTipoRegistro( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'>, data: string ): Promise<'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'> { const registrosHoje = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .order('desc') .collect(); if (registrosHoje.length === 0) { return 'entrada'; } const ultimoRegistro = registrosHoje[0]; switch (ultimoRegistro.tipo) { case 'entrada': return 'saida_almoco'; case 'saida_almoco': return 'retorno_almoco'; case 'retorno_almoco': return 'saida'; case 'saida': // Se já saiu, próximo registro é entrada (novo dia) return 'entrada'; default: return 'entrada'; } } /** * Registra um ponto (entrada, saída, etc.) */ export const registrarPonto = mutation({ args: { imagemId: v.optional(v.id('_storage')), informacoesDispositivo: v.optional( v.object({ ipAddress: v.optional(v.string()), ipPublico: v.optional(v.string()), ipLocal: v.optional(v.string()), userAgent: v.optional(v.string()), browser: v.optional(v.string()), browserVersion: v.optional(v.string()), engine: v.optional(v.string()), sistemaOperacional: v.optional(v.string()), osVersion: v.optional(v.string()), arquitetura: v.optional(v.string()), plataforma: v.optional(v.string()), latitude: v.optional(v.number()), longitude: v.optional(v.number()), precisao: v.optional(v.number()), endereco: v.optional(v.string()), cidade: v.optional(v.string()), estado: v.optional(v.string()), pais: v.optional(v.string()), timezone: v.optional(v.string()), deviceType: v.optional(v.string()), deviceModel: v.optional(v.string()), screenResolution: v.optional(v.string()), coresTela: v.optional(v.number()), idioma: v.optional(v.string()), isMobile: v.optional(v.boolean()), isTablet: v.optional(v.boolean()), isDesktop: v.optional(v.boolean()), connectionType: v.optional(v.string()), memoryInfo: v.optional(v.string()), }) ), timestamp: v.number(), sincronizadoComServidor: v.boolean(), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } if (!usuario.funcionarioId) { throw new Error('Usuário não possui funcionário associado'); } // Verificar se funcionário está ativo const funcionario = await ctx.db.get(usuario.funcionarioId); if (!funcionario) { throw new Error('Funcionário não encontrado'); } // Obter configuração de ponto const config = await ctx.db .query('configuracaoPonto') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (!config) { throw new Error('Configuração de ponto não encontrada'); } // Converter timestamp para data/hora const dataObj = new Date(args.timestamp); const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD const hora = dataObj.getHours(); const minuto = dataObj.getMinutes(); const segundo = dataObj.getSeconds(); // Verificar se já existe registro no mesmo minuto const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined const registrosMinuto = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .collect(); const registroDuplicado = registrosMinuto.find( (r) => r.hora === hora && r.minuto === minuto ); if (registroDuplicado) { throw new Error('Já existe um registro neste minuto'); } // Determinar tipo de registro const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data); // Calcular horário esperado e tolerância let horarioConfigurado = ''; switch (tipo) { case 'entrada': horarioConfigurado = config.horarioEntrada; break; case 'saida_almoco': horarioConfigurado = config.horarioSaidaAlmoco; break; case 'retorno_almoco': horarioConfigurado = config.horarioRetornoAlmoco; break; case 'saida': horarioConfigurado = config.horarioSaida; break; } const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos); // Criar registro const registroId = await ctx.db.insert('registrosPonto', { funcionarioId: usuario.funcionarioId, tipo, data, hora, minuto, segundo, timestamp: args.timestamp, imagemId: args.imagemId, sincronizadoComServidor: args.sincronizadoComServidor, toleranciaMinutos: config.toleranciaMinutos, dentroDoPrazo, ipAddress: args.informacoesDispositivo?.ipAddress, ipPublico: args.informacoesDispositivo?.ipPublico, ipLocal: args.informacoesDispositivo?.ipLocal, userAgent: args.informacoesDispositivo?.userAgent, browser: args.informacoesDispositivo?.browser, browserVersion: args.informacoesDispositivo?.browserVersion, engine: args.informacoesDispositivo?.engine, sistemaOperacional: args.informacoesDispositivo?.sistemaOperacional, osVersion: args.informacoesDispositivo?.osVersion, arquitetura: args.informacoesDispositivo?.arquitetura, plataforma: args.informacoesDispositivo?.plataforma, latitude: args.informacoesDispositivo?.latitude, longitude: args.informacoesDispositivo?.longitude, precisao: args.informacoesDispositivo?.precisao, endereco: args.informacoesDispositivo?.endereco, cidade: args.informacoesDispositivo?.cidade, estado: args.informacoesDispositivo?.estado, pais: args.informacoesDispositivo?.pais, timezone: args.informacoesDispositivo?.timezone, deviceType: args.informacoesDispositivo?.deviceType, deviceModel: args.informacoesDispositivo?.deviceModel, screenResolution: args.informacoesDispositivo?.screenResolution, coresTela: args.informacoesDispositivo?.coresTela, idioma: args.informacoesDispositivo?.idioma, isMobile: args.informacoesDispositivo?.isMobile, isTablet: args.informacoesDispositivo?.isTablet, isDesktop: args.informacoesDispositivo?.isDesktop, connectionType: args.informacoesDispositivo?.connectionType, memoryInfo: args.informacoesDispositivo?.memoryInfo, criadoEm: Date.now(), }); return { registroId, tipo, dentroDoPrazo }; }, }); /** * Lista registros do dia atual do funcionário */ export const listarRegistrosDia = query({ args: { data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario || !usuario.funcionarioId) { return []; } const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined const data = args.data || new Date().toISOString().split('T')[0]!; const registros = await ctx.db .query('registrosPonto') .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .order('asc') .collect(); return registros; }, }); /** * Lista registros por período (para RH) */ export const listarRegistrosPeriodo = query({ args: { funcionarioId: v.optional(v.id('funcionarios')), dataInicio: v.string(), // YYYY-MM-DD dataFim: v.string(), // YYYY-MM-DD }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // Verificar permissão (RH ou TI) // Por enquanto, permitir se tiver funcionarioId ou for admin // TODO: Implementar verificação de permissão adequada const dataInicio = new Date(args.dataInicio); const dataFim = new Date(args.dataFim); dataFim.setHours(23, 59, 59, 999); const registros = await ctx.db .query('registrosPonto') .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) .collect(); // Filtrar por funcionário se especificado let registrosFiltrados = registros; if (args.funcionarioId) { registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId); } // Buscar informações dos funcionários const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId)); const funcionarios = await Promise.all( Array.from(funcionariosIds).map((id) => ctx.db.get(id)) ); return registrosFiltrados.map((registro) => { const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId); return { ...registro, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula, descricaoCargo: funcionario.descricaoCargo, } : null, }; }); }, }); /** * Obtém estatísticas de pontos (para gráficos) */ export const obterEstatisticas = query({ args: { dataInicio: v.string(), // YYYY-MM-DD dataFim: v.string(), // YYYY-MM-DD }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } // TODO: Verificar permissão (RH ou TI) const registros = await ctx.db .query('registrosPonto') .withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim)) .collect(); const totalRegistros = registros.length; const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length; const foraDoPrazo = totalRegistros - dentroDoPrazo; // Agrupar por funcionário const funcionariosUnicos = new Set(registros.map((r) => r.funcionarioId)); const totalFuncionarios = funcionariosUnicos.size; // Funcionários com registros dentro do prazo const funcionariosDentroPrazo = new Set( registros.filter((r) => r.dentroDoPrazo).map((r) => r.funcionarioId) ).size; // Funcionários com registros fora do prazo const funcionariosForaPrazo = totalFuncionarios - funcionariosDentroPrazo; return { totalRegistros, dentroDoPrazo, foraDoPrazo, totalFuncionarios, funcionariosDentroPrazo, funcionariosForaPrazo, }; }, }); /** * Obtém um registro específico (para comprovante) */ export const obterRegistro = query({ args: { registroId: v.id('registrosPonto'), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { throw new Error('Usuário não autenticado'); } const registro = await ctx.db.get(args.registroId); if (!registro) { throw new Error('Registro não encontrado'); } // Verificar se o usuário tem permissão (próprio registro ou RH/TI) if (registro.funcionarioId !== usuario.funcionarioId) { // TODO: Verificar se é RH ou TI // Por enquanto, permitir } const funcionario = await ctx.db.get(registro.funcionarioId); let simbolo = null; if (funcionario) { simbolo = await ctx.db.get(funcionario.simboloId); } return { ...registro, funcionario: funcionario ? { nome: funcionario.nome, matricula: funcionario.matricula, descricaoCargo: funcionario.descricaoCargo, simbolo: simbolo ? { nome: simbolo.nome, tipo: simbolo.tipo, } : null, } : null, }; }, });