Files
sgse-app/packages/backend/convex/pontos.ts
deyvisonwanderley db61df1fb4 feat: add new features for point management and registration
- Introduced "Homologação de Registro" and "Dispensa de Registro" sections in the dashboard for enhanced point management.
- Updated the WidgetGestaoPontos component to include new links and icons for the added features.
- Enhanced backend functionality to support the new features, including querying and managing dispensas and homologações.
- Improved the PDF generation process to include daily balance calculations for employee time records.
- Implemented checks for active dispensas to prevent unauthorized point registrations.
2025-11-19 16:37:31 -03:00

1335 lines
38 KiB
TypeScript

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');
}
// Verificar se funcionário está dispensado de registrar ponto
const dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
const dataConsulta = new Date(data);
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
if (dispensa.isento) {
throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)');
}
// Verificar se está no período
const dataInicio = new Date(dispensa.dataInicio);
const dataFim = new Date(dispensa.dataFim);
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
// Verificar hora e minuto se necessário
const timestampConsulta = new Date(
`${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
}
}
// Verificar se expirou (desativar na mutation de registro)
const agora = new Date();
const dataFimTimestamp = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (agora.getTime() > dataFimTimestamp && !dispensa.isento) {
// Desativar dispensa expirada (mutation pode fazer isso)
await ctx.db.patch(dispensa._id, {
ativo: false,
});
}
}
// Determinar tipo de registro
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
// Calcular horário esperado e tolerância
let horarioConfigurado = '';
switch (tipo) {
case 'entrada':
horarioConfigurado = config.horarioEntrada;
break;
case 'saida_almoco':
horarioConfigurado = config.horarioSaidaAlmoco;
break;
case 'retorno_almoco':
horarioConfigurado = config.horarioRetornoAlmoco;
break;
case 'saida':
horarioConfigurado = config.horarioSaida;
break;
}
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
// 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;
},
});
/**
* Obtém saldo diário de um funcionário para uma data específica
*/
export const obterSaldoDiario = query({
args: {
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
},
handler: async (ctx, args) => {
// Buscar banco de horas do dia
const bancoHoras = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', args.funcionarioId).eq('data', args.data)
)
.first();
if (!bancoHoras) {
return {
saldoMinutos: 0,
horas: 0,
minutos: 0,
positivo: true,
};
}
const horas = Math.floor(Math.abs(bancoHoras.saldoMinutos) / 60);
const minutos = Math.abs(bancoHoras.saldoMinutos) % 60;
const positivo = bancoHoras.saldoMinutos >= 0;
return {
saldoMinutos: bancoHoras.saldoMinutos,
horas,
minutos,
positivo,
};
},
});
/**
* Lista registros por período (para RH)
*/
export const listarRegistrosPeriodo = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
dataInicio: v.string(), // YYYY-MM-DD
dataFim: v.string(), // YYYY-MM-DD
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
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))
);
// Buscar saldos diários para cada data/funcionário
const saldosPorDataFuncionario: Record<string, number> = {};
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;
}
}
return registrosFiltrados.map((registro) => {
const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId);
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,
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
},
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<void> {
// 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;
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
const minutos = Math.abs(saldoMinutos) % 60;
const positivo = saldoMinutos >= 0;
return {
registros,
cargaHorariaDiaria,
horasTrabalhadas,
saldoMinutos,
saldoFormatado: {
horas,
minutos,
positivo,
},
};
},
});
/**
* Obtém banco de horas acumulado do funcionário
*/
export const obterBancoHorasFuncionario = query({
args: {
funcionarioId: v.id('funcionarios'),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se é o próprio funcionário ou tem permissão
if (usuario.funcionarioId !== args.funcionarioId) {
// TODO: Verificar permissão de RH
}
// Buscar todos os registros de banco de horas do funcionário
const bancosHoras = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.order('desc')
.collect();
// Calcular saldo acumulado
const saldoAcumuladoMinutos = bancosHoras.reduce((acc, bh) => acc + bh.saldoMinutos, 0);
return {
bancosHoras,
saldoAcumuladoMinutos,
totalDias: bancosHoras.length,
};
},
});
/**
* Helper: Verificar se usuário é gestor do funcionário
*/
async function verificarGestorDoFuncionario(
ctx: QueryCtx | MutationCtx,
gestorId: Id<'usuarios'>,
funcionarioId: Id<'funcionarios'>
): Promise<boolean> {
const membroTime = await ctx.db
.query('timesMembros')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.first();
if (!membroTime) return false;
const time = await ctx.db.get(membroTime.timeId);
if (!time) return false;
return time.gestorId === gestorId;
}
/**
* Edita um registro de ponto (homologação pelo gestor)
*/
export const editarRegistroPonto = mutation({
args: {
registroId: v.id('registrosPonto'),
horaNova: v.number(),
minutoNova: v.number(),
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
observacoes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Buscar registro
const registro = await ctx.db.get(args.registroId);
if (!registro) {
throw new Error('Registro não encontrado');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, registro.funcionarioId);
if (!isGestor) {
throw new Error('Você não tem permissão para editar este registro');
}
// Salvar dados anteriores
const horaAnterior = registro.hora;
const minutoAnterior = registro.minuto;
// Atualizar registro
await ctx.db.patch(args.registroId, {
hora: args.horaNova,
minuto: args.minutoNova,
editadoPorGestor: true,
});
// Criar registro de homologação
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
registroId: args.registroId,
funcionarioId: registro.funcionarioId,
gestorId: usuario._id,
horaAnterior,
minutoAnterior,
horaNova: args.horaNova,
minutoNova: args.minutoNova,
motivoId: args.motivoId,
motivoTipo: args.motivoTipo,
motivoDescricao: args.motivoDescricao,
observacoes: args.observacoes,
criadoEm: Date.now(),
});
// Atualizar registro com ID da homologação
await ctx.db.patch(args.registroId, {
homologacaoId,
});
// Recalcular banco de horas do dia
const config = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (config) {
await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config);
}
return { success: true, homologacaoId };
},
});
/**
* Ajusta banco de horas (compensar, abonar ou descontar)
*/
export const ajustarBancoHoras = mutation({
args: {
funcionarioId: v.id('funcionarios'),
tipoAjuste: v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')),
periodoDias: v.number(),
periodoHoras: v.number(),
periodoMinutos: v.number(),
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
observacoes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId);
if (!isGestor) {
throw new Error('Você não tem permissão para ajustar banco de horas deste funcionário');
}
// Calcular ajuste em minutos
const ajusteMinutos =
args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos;
// Aplicar sinal baseado no tipo de ajuste
let ajusteFinal = ajusteMinutos;
if (args.tipoAjuste === 'descontar') {
ajusteFinal = -ajusteMinutos;
}
// Buscar banco de horas mais recente ou criar um registro de ajuste
const hoje = new Date().toISOString().split('T')[0]!;
const bancoHorasAtual = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', args.funcionarioId).eq('data', hoje)
)
.first();
if (bancoHorasAtual) {
// Atualizar saldo do dia atual
await ctx.db.patch(bancoHorasAtual._id, {
saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal,
});
} else {
// Criar novo registro de banco de horas para o ajuste
const config = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
await ctx.db.insert('bancoHoras', {
funcionarioId: args.funcionarioId,
data: hoje,
cargaHorariaDiaria,
horasTrabalhadas: 0,
saldoMinutos: ajusteFinal,
registrosPontoIds: [],
calculadoEm: Date.now(),
});
}
// Criar registro de homologação
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
funcionarioId: args.funcionarioId,
gestorId: usuario._id,
motivoId: args.motivoId,
motivoTipo: args.motivoTipo,
motivoDescricao: args.motivoDescricao,
observacoes: args.observacoes,
tipoAjuste: args.tipoAjuste,
periodoDias: args.periodoDias,
periodoHoras: args.periodoHoras,
periodoMinutos: args.periodoMinutos,
ajusteMinutos: ajusteFinal,
criadoEm: Date.now(),
});
return { success: true, homologacaoId, ajusteMinutos: ajusteFinal };
},
});
/**
* Lista homologações de um funcionário ou time
*/
export const listarHomologacoes = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
let homologacoes;
if (args.funcionarioId) {
// Verificar se é gestor do funcionário ou o próprio funcionário
const funcionarioId = args.funcionarioId; // Garantir que não é undefined
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId);
const isProprioFuncionario = usuario.funcionarioId === funcionarioId;
if (!isGestor && !isProprioFuncionario) {
throw new Error('Você não tem permissão para ver estas homologações');
}
homologacoes = await ctx.db
.query('homologacoesPonto')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.order('desc')
.collect();
} else {
// Listar homologações do gestor
homologacoes = await ctx.db
.query('homologacoesPonto')
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
.order('desc')
.collect();
}
// Buscar informações adicionais
const homologacoesComDetalhes = await Promise.all(
homologacoes.map(async (h) => {
const funcionario = await ctx.db.get(h.funcionarioId);
const gestor = await ctx.db.get(h.gestorId);
const registro = h.registroId ? await ctx.db.get(h.registroId) : null;
return {
...h,
funcionario: funcionario
? {
nome: funcionario.nome,
matricula: funcionario.matricula,
}
: null,
gestor: gestor
? {
nome: gestor.nome,
}
: null,
registro: registro
? {
data: registro.data,
tipo: registro.tipo,
}
: null,
};
})
);
return homologacoesComDetalhes;
},
});
/**
* Obtém opções de motivos de atestados/declarações
*/
export const obterMotivosAtestados = query({
args: {},
handler: async (ctx) => {
// Buscar tipos de atestados e declarações
const atestados = await ctx.db.query('atestados').collect();
const tiposUnicos = new Set<string>();
atestados.forEach((a) => {
if (a.cid) tiposUnicos.add(`CID: ${a.cid}`);
if (a.observacoes) tiposUnicos.add(a.observacoes);
});
return {
tipos: Array.from(tiposUnicos),
opcoesPadrao: [
'Atestado Médico',
'Declaração',
'Ajuste Administrativo',
'Compensação de Horas',
'Abono',
'Desconto em Folha',
],
};
},
});
/**
* Cria uma dispensa de registro de ponto
*/
export const criarDispensaRegistro = mutation({
args: {
funcionarioId: v.id('funcionarios'),
dataInicio: v.string(), // YYYY-MM-DD
horaInicio: v.number(),
minutoInicio: v.number(),
dataFim: v.string(), // YYYY-MM-DD
horaFim: v.number(),
minutoFim: v.number(),
motivo: v.string(),
isento: v.boolean(),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId);
if (!isGestor) {
throw new Error('Você não tem permissão para criar dispensa para este funcionário');
}
// Validar datas
const dataInicioObj = new Date(args.dataInicio);
const dataFimObj = new Date(args.dataFim);
if (dataFimObj < dataInicioObj) {
throw new Error('Data fim deve ser maior ou igual à data início');
}
// Criar dispensa
const dispensaId = await ctx.db.insert('dispensasRegistro', {
funcionarioId: args.funcionarioId,
gestorId: usuario._id,
dataInicio: args.dataInicio,
horaInicio: args.horaInicio,
minutoInicio: args.minutoInicio,
dataFim: args.dataFim,
horaFim: args.horaFim,
minutoFim: args.minutoFim,
motivo: args.motivo,
isento: args.isento,
ativo: true,
criadoEm: Date.now(),
});
return { success: true, dispensaId };
},
});
/**
* Remove uma dispensa de registro (cancela)
*/
export const removerDispensaRegistro = mutation({
args: {
dispensaId: v.id('dispensasRegistro'),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const dispensa = await ctx.db.get(args.dispensaId);
if (!dispensa) {
throw new Error('Dispensa não encontrada');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, dispensa.funcionarioId);
if (!isGestor && dispensa.gestorId !== usuario._id) {
throw new Error('Você não tem permissão para remover esta dispensa');
}
// Desativar dispensa
await ctx.db.patch(args.dispensaId, {
ativo: false,
});
return { success: true };
},
});
/**
* Lista dispensas de registro
*/
export const listarDispensas = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
apenasAtivas: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
let dispensas;
if (args.funcionarioId) {
// Verificar se é gestor do funcionário ou o próprio funcionário
const funcionarioId = args.funcionarioId; // Garantir que não é undefined
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId);
const isProprioFuncionario = usuario.funcionarioId === funcionarioId;
if (!isGestor && !isProprioFuncionario) {
throw new Error('Você não tem permissão para ver estas dispensas');
}
dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => {
if (args.apenasAtivas !== undefined && args.apenasAtivas) {
return q.eq(q.field('ativo'), true);
}
return true; // Retornar todas se apenasAtivas não for especificado
})
.order('desc')
.collect();
} else {
// Listar dispensas do gestor
dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
.filter((q) => {
if (args.apenasAtivas !== undefined && args.apenasAtivas) {
return q.eq(q.field('ativo'), true);
}
return true; // Retornar todas se apenasAtivas não for especificado
})
.order('desc')
.collect();
}
// Buscar informações adicionais
const dispensasComDetalhes = await Promise.all(
dispensas.map(async (d) => {
const funcionario = await ctx.db.get(d.funcionarioId);
const gestor = await ctx.db.get(d.gestorId);
// 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,
gestor: gestor
? {
nome: gestor.nome,
}
: null,
expirada,
};
})
);
return dispensasComDetalhes;
},
});
/**
* Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica
*/
export const verificarDispensaAtiva = query({
args: {
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
hora: v.optional(v.number()),
minuto: v.optional(v.number()),
},
handler: async (ctx, args) => {
const dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
const dataConsulta = new Date(args.data);
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
if (dispensa.isento) {
return {
dispensado: true,
dispensa,
motivo: 'Isento de registro (caso excepcional)',
};
}
// Verificar se está no período
const dataInicio = new Date(dispensa.dataInicio);
const dataFim = new Date(dispensa.dataFim);
// Se a data está dentro do período
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
// Se hora e minuto foram fornecidos, verificar também
if (args.hora !== undefined && args.minuto !== undefined) {
const timestampConsulta = new Date(
`${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo,
};
}
} else {
// Apenas verificar data
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo,
};
}
}
}
return {
dispensado: false,
dispensa: null,
motivo: null,
};
},
});