Files
sgse-app/packages/backend/convex/pontos.ts

4914 lines
146 KiB
TypeScript

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();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
// A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto que já estão em UTC
function criarTimestampUTC(data: string, horaUTC: number, minutoUTC: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, horaUTC, minutoUTC, 0, 0);
}
// Obter timestamp atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
// Timestamp da consulta (registro sendo feito) em UTC
// hora/minuto já estão em UTC (extraídos com getUTCHours/getUTCMinutes)
const timestampConsultaUTC = criarTimestampUTC(data, hora, minuto);
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)');
}
// Calcular timestamps de início e fim da dispensa em UTC
const timestampInicioUTC = criarTimestampUTCDeGMT3(
dispensa.dataInicio,
dispensa.horaInicio,
dispensa.minutoInicio
);
const timestampFimUTC = criarTimestampUTCDeGMT3(
dispensa.dataFim,
dispensa.horaFim,
dispensa.minutoFim
);
// Desativar dispensa expirada ANTES de verificar bloqueio (após o fim)
// Verificar se AGORA já passou do horário de fim da dispensa
if (agoraTimestampUTC > timestampFimUTC) {
await ctx.db.patch(dispensa._id, {
ativo: false
});
continue; // Pular verificação de bloqueio se já expirou
}
// Verificar se AGORA está dentro do período da dispensa (não o horário do registro)
// Se o momento atual está dentro do período, bloqueia qualquer tentativa de registro
if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
}
}
// 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<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;
}
}
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 };
}
/**
* Remove ajustes automáticos relacionados a um registro excluído
* Busca e remove ajustes que referenciam o motivoId fornecido
*/
async function removerAjustesAutomaticos(
ctx: MutationCtx,
funcionarioId: Id<'funcionarios'>,
motivoTipo: 'atestado' | 'licenca' | 'ausencia',
motivoId: string,
dataInicio: string,
dataFim: string
): Promise<void> {
// Gerar todas as datas do período
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(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);
}
// Buscar todos os ajustes automáticos relacionados ao motivoId no período
for (const data of datas) {
const ajustes = await ctx.db
.query('ajustesBancoHoras')
.filter((q) =>
q.and(
q.eq(q.field('funcionarioId'), funcionarioId),
q.eq(q.field('dataAplicacao'), data),
q.eq(q.field('motivoTipo'), motivoTipo),
q.eq(q.field('motivoId'), motivoId),
q.eq(q.field('aplicado'), true)
)
)
.collect();
// Remover cada ajuste encontrado
for (const ajuste of ajustes) {
await ctx.db.delete(ajuste._id);
}
}
}
/**
* 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<Id<'registrosPonto'>>,
atestadoInfo: { temAtestado: boolean; atestadoId?: string },
licencaInfo: { temLicenca: boolean; licencaId?: string },
ausenciaInfo: { temAusencia: boolean; ausenciaId?: string }
): Promise<Array<Id<'inconsistenciasBancoHoras'>>> {
const inconsistenciaIds: Array<Id<'inconsistenciasBancoHoras'>> = [];
// 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<Id<'ajustesBancoHoras'>> {
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<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);
// 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<Id<'ajustesBancoHoras'>> = [];
// Aplicar ajustes automáticos se houver atestado, licença ou ausência
// IMPORTANTE: Verificar se o registro ainda existe antes de criar ajuste
if (atestadoInfo.temAtestado && atestadoInfo.atestadoId) {
// Verificar se o atestado ainda existe no banco
const atestado = await ctx.db.get(atestadoInfo.atestadoId as Id<'atestados'>);
if (atestado) {
tipoDia = 'atestado';
motivoAbono = atestadoInfo.motivo;
const ajusteId = await aplicarAjusteAutomatico(
ctx,
funcionarioId,
data,
cargaHorariaDiaria,
'atestado',
atestadoInfo.atestadoId,
atestadoInfo.motivo || 'Atestado Médico'
);
ajustesIds.push(ajusteId);
}
} else if (licencaInfo.temLicenca && licencaInfo.licencaId) {
// Verificar se a licença ainda existe no banco
const licenca = await ctx.db.get(licencaInfo.licencaId as Id<'licencas'>);
if (licenca) {
tipoDia = 'licenca';
motivoAbono = licencaInfo.motivo;
const ajusteId = await aplicarAjusteAutomatico(
ctx,
funcionarioId,
data,
cargaHorariaDiaria,
'licenca',
licencaInfo.licencaId,
licencaInfo.motivo || 'Licença'
);
ajustesIds.push(ajusteId);
}
} else if (ausenciaInfo.temAusencia && ausenciaInfo.ausenciaId) {
// Verificar se a ausência ainda existe no banco e está aprovada
const ausencia = await ctx.db.get(ausenciaInfo.ausenciaId as Id<'solicitacoesAusencias'>);
if (ausencia && ausencia.status === 'aprovado') {
tipoDia = 'ausencia';
motivoAbono = ausenciaInfo.motivo;
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<void> {
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<void> {
// 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<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) => {
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, restaurar valores originais
if (homologacao.registroId) {
const registro = await ctx.db.get(homologacao.registroId);
if (registro && registro.homologacaoId === args.homologacaoId) {
// Restaurar valores originais se existirem
const patchData: {
homologacaoId: undefined;
editadoPorGestor: boolean;
hora?: number;
minuto?: number;
} = {
homologacaoId: undefined,
editadoPorGestor: false
};
// Se a homologação tem valores anteriores, restaurar
if (homologacao.horaAnterior !== undefined && homologacao.minutoAnterior !== undefined) {
patchData.hora = homologacao.horaAnterior;
patchData.minuto = homologacao.minutoAnterior;
}
await ctx.db.patch(homologacao.registroId, patchData);
// Recalcular banco de horas após restaurar valores
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);
}
}
}
// Se for um ajuste de banco de horas, remover completamente do banco de dados
if (homologacao.tipoAjuste && homologacao.ajusteMinutos !== undefined) {
// Converter criadoEm da homologação para data (YYYY-MM-DD)
const dataHomologacao = new Date(homologacao.criadoEm).toISOString().split('T')[0]!;
// Buscar o ajuste correspondente
// Procurar ajustes manuais do mesmo funcionário, gestor, tipo, valor e data
const ajustes = await ctx.db
.query('ajustesBancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', homologacao.funcionarioId).eq('dataAplicacao', dataHomologacao)
)
.filter((q) =>
q.and(
q.eq(q.field('motivoTipo'), 'manual'),
q.eq(q.field('tipo'), homologacao.tipoAjuste),
q.eq(q.field('valorMinutos'), homologacao.ajusteMinutos),
q.eq(q.field('gestorId'), homologacao.gestorId)
)
)
.collect();
// Se encontrou ajuste(s), encontrar o mais próximo em tempo à homologação
if (ajustes.length > 0) {
// Encontrar o ajuste com timestamp mais próximo ao da homologação
// (o ajuste geralmente é criado um pouco antes da homologação)
let ajusteMaisProximo = ajustes[0]!;
let menorDiferenca = Math.abs(ajustes[0]!.criadoEm - homologacao.criadoEm);
for (const ajusteCandidato of ajustes) {
const diferenca = Math.abs(ajusteCandidato.criadoEm - homologacao.criadoEm);
if (diferenca < menorDiferenca) {
menorDiferenca = diferenca;
ajusteMaisProximo = ajusteCandidato;
}
}
const ajuste = ajusteMaisProximo;
// Buscar o banco de horas do dia onde o ajuste foi aplicado
const bancoHoras = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', homologacao.funcionarioId).eq('data', ajuste.dataAplicacao)
)
.first();
if (bancoHoras) {
// Remover o ajuste do array ajustesIds
const novosAjustesIds = (bancoHoras.ajustesIds || []).filter(
(id) => id !== ajuste._id
);
// Reverter o ajuste do saldo (subtrair o valor que foi adicionado)
const novoSaldoMinutos = bancoHoras.saldoMinutos - ajuste.valorMinutos;
// Verificar se ainda há outros ajustes ou se precisa resetar tipoDia
let novoTipoDia = bancoHoras.tipoDia;
if (novosAjustesIds.length > 0) {
// Se ainda há outros ajustes, verificar qual tipoDia deve ser mantido
const outrosAjustes = await Promise.all(
novosAjustesIds.map((id) => ctx.db.get(id))
);
const temAjusteAbonar = outrosAjustes.some((a) => a?.tipo === 'abonar');
const temAjusteDescontar = outrosAjustes.some((a) => a?.tipo === 'descontar');
// Se há ajuste de abonar, manter ou definir como 'abonado'
if (temAjusteAbonar) {
novoTipoDia = 'abonado';
} else if (temAjusteDescontar) {
// Se há ajuste de descontar, manter ou definir como 'descontado'
novoTipoDia = 'descontado';
} else {
// Se não há ajustes que determinem tipoDia, resetar
novoTipoDia = undefined;
}
} else {
// Se não há mais ajustes, verificar se deve resetar tipoDia
// Se o tipoDia estava relacionado ao ajuste removido, resetar
if (
(bancoHoras.tipoDia === 'abonado' && ajuste.tipo === 'abonar') ||
(bancoHoras.tipoDia === 'descontado' && ajuste.tipo === 'descontar')
) {
novoTipoDia = undefined;
}
}
// Atualizar banco de horas
await ctx.db.patch(bancoHoras._id, {
saldoMinutos: novoSaldoMinutos,
ajustesIds: novosAjustesIds.length > 0 ? novosAjustesIds : undefined,
tipoDia: novoTipoDia
});
// Recalcular banco de horas mensal após remover ajuste
const mes = ajuste.dataAplicacao.substring(0, 7); // YYYY-MM
const hojeDate = new Date();
const mesAtual = `${hojeDate.getFullYear()}-${String(hojeDate.getMonth() + 1).padStart(2, '0')}`;
const estaRemovendoMesPassado = mes < mesAtual;
// Recalcular em cascata se for mês passado
await calcularBancoHorasMensal(ctx, homologacao.funcionarioId, mes, estaRemovendoMesPassado);
}
// Excluir o registro de ajuste do banco de dados
await ctx.db.delete(ajuste._id);
}
}
// 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<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) => {
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 (comparar strings diretamente para evitar problemas de timezone)
// Formato YYYY-MM-DD permite comparação lexicográfica
if (args.dataFim < args.dataInicio) {
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');
}
// Deletar dispensa do banco de dados
await ctx.db.delete(args.dispensaId);
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 está ativa ou expirada (considerando data, hora e minuto em GMT-3)
let expirada = false;
// GMT-3 está 3 horas ATRÁS do UTC
// Offset: +3 horas para converter GMT-3 para UTC
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
// Obter data/hora atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
// A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC
// Exemplo: 08:00 GMT-3 = 11:00 UTC
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
// Date.UTC cria timestamp UTC
// Se a hora está em GMT-3, adicionamos 3 horas para obter o equivalente UTC
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
if (!d.isento) {
// Para dispensas não isentas, verificar se está dentro do período
const dataInicioTimestamp = criarTimestampUTCDeGMT3(
d.dataInicio,
d.horaInicio,
d.minutoInicio
);
const dataFimTimestamp = criarTimestampUTCDeGMT3(d.dataFim, d.horaFim, d.minutoFim);
// Está expirada se estiver antes do início OU depois do fim
// Está ativa se: dataInicioTimestamp <= agoraTimestampUTC <= dataFimTimestamp
expirada =
agoraTimestampUTC < dataInicioTimestamp || agoraTimestampUTC > dataFimTimestamp;
} else {
// Se for isento, verificar apenas se já passou do início
const dataInicioTimestamp = criarTimestampUTCDeGMT3(
d.dataInicio,
d.horaInicio,
d.minutoInicio
);
// Se ainda não começou, está expirada (não ativa ainda)
expirada = agoraTimestampUTC < dataInicioTimestamp;
}
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();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
// Obter timestamp atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
if (dispensa.isento) {
return {
dispensado: true,
dispensa,
motivo: 'Isento de registro (caso excepcional)'
};
}
// Calcular timestamps de início e fim da dispensa em UTC
const timestampInicioUTC = criarTimestampUTCDeGMT3(
dispensa.dataInicio,
dispensa.horaInicio,
dispensa.minutoInicio
);
const timestampFimUTC = criarTimestampUTCDeGMT3(
dispensa.dataFim,
dispensa.horaFim,
dispensa.minutoFim
);
// Verificar se AGORA já passou do horário de fim da dispensa
// Se já expirou, não está mais dispensado
if (agoraTimestampUTC > timestampFimUTC) {
// Dispensa expirada, continuar para próxima
continue;
}
// Se hora e minuto foram fornecidos, verificar timestamp completo
if (args.hora !== undefined && args.minuto !== undefined) {
const timestampConsultaUTC = criarTimestampUTCDeGMT3(args.data, args.hora, args.minuto);
if (timestampConsultaUTC >= timestampInicioUTC && timestampConsultaUTC <= timestampFimUTC) {
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo
};
}
} else {
// Se apenas data foi fornecida, verificar se AGORA está dentro do período
// (não apenas a data, mas também o horário)
if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) {
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<typeof a> => 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<typeof i> => 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
*/
/**
* Internal mutation para remover ajustes automáticos relacionados a um registro excluído
*/
export const removerAjustesAutomaticosInternal = internalMutation({
args: {
funcionarioId: v.id('funcionarios'),
motivoTipo: v.union(v.literal('atestado'), v.literal('licenca'), v.literal('ausencia')),
motivoId: v.string(),
dataInicio: v.string(),
dataFim: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
await removerAjustesAutomaticos(
ctx,
args.funcionarioId,
args.motivoTipo,
args.motivoId,
args.dataInicio,
args.dataFim
);
return null;
}
});
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<Id<'usuarios'> | 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<void> {
// 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<void> {
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 };
}
});