- Implemented user authentication checks in the `getAll` and `listarRegistrosPeriodo` query handlers, returning empty arrays for unauthenticated users. - Enhanced date validation in `listarRegistrosPeriodo` to ensure correct date formats before processing. - Updated the `obterEstatisticas` query to return zeroed statistics for unauthenticated users, improving data security and user experience.
1912 lines
58 KiB
TypeScript
1912 lines
58 KiB
TypeScript
import { v } from 'convex/values';
|
|
import { mutation, query } from './_generated/server';
|
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
|
import { getCurrentUserFunction } from './auth';
|
|
import type { Id } from './_generated/dataModel';
|
|
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
|
|
|
|
/**
|
|
* 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 = undefined;
|
|
let velocidadeUltimoRegistro: number | undefined = undefined;
|
|
let distanciaUltimoRegistro: number | undefined = undefined;
|
|
let tempoDecorridoHoras: number | undefined = 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) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
return await ctx.storage.generateUploadUrl();
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Calcula se o registro está dentro do prazo baseado na configuração
|
|
* Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true)
|
|
*/
|
|
function calcularStatusPonto(
|
|
hora: number,
|
|
minuto: number,
|
|
horarioConfigurado: string,
|
|
toleranciaMinutos: number
|
|
): boolean {
|
|
// Se tolerância for 0, desconsiderar atrasos (qualquer registro é válido)
|
|
if (toleranciaMinutos === 0) {
|
|
return true;
|
|
}
|
|
|
|
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
|
|
const totalMinutosRegistro = hora * 60 + minuto;
|
|
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
|
|
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
|
|
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
|
|
}
|
|
|
|
/**
|
|
* Determina o tipo de registro baseado na sequência lógica
|
|
*/
|
|
async function determinarTipoRegistro(
|
|
ctx: QueryCtx | MutationCtx,
|
|
funcionarioId: Id<'funcionarios'>,
|
|
data: string
|
|
): Promise<'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'> {
|
|
const registrosHoje = await ctx.db
|
|
.query('registrosPonto')
|
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
|
.order('desc')
|
|
.collect();
|
|
|
|
if (registrosHoje.length === 0) {
|
|
return 'entrada';
|
|
}
|
|
|
|
const ultimoRegistro = registrosHoje[0];
|
|
switch (ultimoRegistro.tipo) {
|
|
case 'entrada':
|
|
return 'saida_almoco';
|
|
case 'saida_almoco':
|
|
return 'retorno_almoco';
|
|
case 'retorno_almoco':
|
|
return 'saida';
|
|
case 'saida':
|
|
// Se já saiu, próximo registro é entrada (novo dia)
|
|
return 'entrada';
|
|
default:
|
|
return 'entrada';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registra um ponto (entrada, saída, etc.)
|
|
*/
|
|
export const registrarPonto = mutation({
|
|
args: {
|
|
imagemId: v.optional(v.id('_storage')),
|
|
informacoesDispositivo: v.optional(
|
|
v.object({
|
|
ipAddress: v.optional(v.string()),
|
|
ipPublico: v.optional(v.string()),
|
|
ipLocal: v.optional(v.string()),
|
|
userAgent: v.optional(v.string()),
|
|
browser: v.optional(v.string()),
|
|
browserVersion: v.optional(v.string()),
|
|
engine: v.optional(v.string()),
|
|
sistemaOperacional: v.optional(v.string()),
|
|
osVersion: v.optional(v.string()),
|
|
arquitetura: v.optional(v.string()),
|
|
plataforma: v.optional(v.string()),
|
|
latitude: v.optional(v.number()),
|
|
longitude: v.optional(v.number()),
|
|
precisao: v.optional(v.number()),
|
|
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) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
if (!usuario.funcionarioId) {
|
|
throw new Error('Usuário não possui funcionário associado');
|
|
}
|
|
|
|
// Verificar se funcionário está ativo
|
|
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
|
if (!funcionario) {
|
|
throw new Error('Funcionário não encontrado');
|
|
}
|
|
|
|
// Obter configuração de ponto
|
|
const config = await ctx.db
|
|
.query('configuracaoPonto')
|
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
.first();
|
|
|
|
if (!config) {
|
|
throw new Error('Configuração de ponto não encontrada');
|
|
}
|
|
|
|
// Converter timestamp para data/hora
|
|
// 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}`;
|
|
|
|
// Verificar se já existe registro no mesmo minuto
|
|
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
|
const registrosMinuto = await ctx.db
|
|
.query('registrosPonto')
|
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
|
.collect();
|
|
|
|
const registroDuplicado = registrosMinuto.find(
|
|
(r) => r.hora === hora && r.minuto === minuto
|
|
);
|
|
|
|
if (registroDuplicado) {
|
|
throw new Error('Já existe um registro neste minuto');
|
|
}
|
|
|
|
// Verificar se funcionário está dispensado de registrar ponto
|
|
const dispensas = await ctx.db
|
|
.query('dispensasRegistro')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.collect();
|
|
|
|
const dataConsulta = new Date(data);
|
|
for (const dispensa of dispensas) {
|
|
// Se for isento, sempre está dispensado
|
|
if (dispensa.isento) {
|
|
throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)');
|
|
}
|
|
|
|
// Verificar se está no período
|
|
const dataInicio = new Date(dispensa.dataInicio);
|
|
const dataFim = new Date(dispensa.dataFim);
|
|
|
|
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
|
|
// Verificar hora e minuto se necessário
|
|
const timestampConsulta = new Date(
|
|
`${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
const timestampInicio = new Date(
|
|
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
const timestampFim = new Date(
|
|
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
|
|
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
|
|
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
|
|
}
|
|
}
|
|
|
|
// Verificar se expirou (desativar na mutation de registro)
|
|
const agora = new Date();
|
|
const dataFimTimestamp = new Date(
|
|
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
|
|
if (agora.getTime() > dataFimTimestamp && !dispensa.isento) {
|
|
// Desativar dispensa expirada (mutation pode fazer isso)
|
|
await ctx.db.patch(dispensa._id, {
|
|
ativo: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Determinar tipo de registro
|
|
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
|
|
|
|
// Calcular horário esperado e tolerância
|
|
let horarioConfigurado = '';
|
|
switch (tipo) {
|
|
case 'entrada':
|
|
horarioConfigurado = config.horarioEntrada;
|
|
break;
|
|
case 'saida_almoco':
|
|
horarioConfigurado = config.horarioSaidaAlmoco;
|
|
break;
|
|
case 'retorno_almoco':
|
|
horarioConfigurado = config.horarioRetornoAlmoco;
|
|
break;
|
|
case 'saida':
|
|
horarioConfigurado = config.horarioSaida;
|
|
break;
|
|
}
|
|
|
|
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
|
|
|
|
// 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
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario || !usuario.funcionarioId) {
|
|
return [];
|
|
}
|
|
|
|
const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined
|
|
const data = args.data || new Date().toISOString().split('T')[0]!;
|
|
|
|
const registros = await ctx.db
|
|
.query('registrosPonto')
|
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
|
.order('asc')
|
|
.collect();
|
|
|
|
return registros;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém saldo diário de um funcionário para uma data específica
|
|
*/
|
|
export const obterSaldoDiario = query({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
data: v.string(), // YYYY-MM-DD
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// Buscar banco de horas do dia
|
|
const bancoHoras = await ctx.db
|
|
.query('bancoHoras')
|
|
.withIndex('by_funcionario_data', (q) =>
|
|
q.eq('funcionarioId', args.funcionarioId).eq('data', args.data)
|
|
)
|
|
.first();
|
|
|
|
if (!bancoHoras) {
|
|
return {
|
|
saldoMinutos: 0,
|
|
horas: 0,
|
|
minutos: 0,
|
|
positivo: true,
|
|
};
|
|
}
|
|
|
|
const horas = Math.floor(Math.abs(bancoHoras.saldoMinutos) / 60);
|
|
const minutos = Math.abs(bancoHoras.saldoMinutos) % 60;
|
|
const positivo = bancoHoras.saldoMinutos >= 0;
|
|
|
|
return {
|
|
saldoMinutos: bancoHoras.saldoMinutos,
|
|
horas,
|
|
minutos,
|
|
positivo,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Lista registros por período (para RH)
|
|
*/
|
|
export const listarRegistrosPeriodo = query({
|
|
args: {
|
|
funcionarioId: v.optional(v.id('funcionarios')),
|
|
dataInicio: v.string(), // YYYY-MM-DD
|
|
dataFim: v.string(), // YYYY-MM-DD
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
// Retornar array vazio quando não autenticado
|
|
return [];
|
|
}
|
|
|
|
// Verificar permissão (RH ou TI)
|
|
// Por enquanto, permitir se tiver funcionarioId ou for admin
|
|
// TODO: Implementar verificação de permissão adequada
|
|
|
|
// Validar formato das datas
|
|
if (!args.dataInicio || !args.dataFim) {
|
|
return [];
|
|
}
|
|
|
|
// Validar formato YYYY-MM-DD
|
|
const dataInicioRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
if (!dataInicioRegex.test(args.dataInicio) || !dataInicioRegex.test(args.dataFim)) {
|
|
return [];
|
|
}
|
|
|
|
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)
|
|
// Usar comparação de strings diretamente para datas no formato YYYY-MM-DD
|
|
const registros = await ctx.db
|
|
.query('registrosPonto')
|
|
.withIndex('by_data', (q) =>
|
|
q.gte('data', args.dataInicio).lte('data', args.dataFim)
|
|
)
|
|
.collect();
|
|
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
// Buscar informações dos funcionários
|
|
const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId));
|
|
const funcionarios = await Promise.all(
|
|
Array.from(funcionariosIds).map((id) => ctx.db.get(id))
|
|
);
|
|
|
|
// Buscar saldos diários para cada data/funcionário
|
|
const saldosPorDataFuncionario: Record<string, number> = {};
|
|
const datasUnicas = new Set(registrosFiltrados.map((r) => `${r.funcionarioId}-${r.data}`));
|
|
|
|
for (const chave of datasUnicas) {
|
|
const [funcId, data] = chave.split('-');
|
|
const bancoHoras = await ctx.db
|
|
.query('bancoHoras')
|
|
.withIndex('by_funcionario_data', (q) =>
|
|
q.eq('funcionarioId', funcId as Id<'funcionarios'>).eq('data', data)
|
|
)
|
|
.first();
|
|
|
|
if (bancoHoras) {
|
|
saldosPorDataFuncionario[chave] = bancoHoras.saldoMinutos;
|
|
}
|
|
}
|
|
|
|
return registrosFiltrados.map((registro) => {
|
|
const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId);
|
|
const chave = `${registro.funcionarioId}-${registro.data}`;
|
|
const saldoMinutos = saldosPorDataFuncionario[chave] || 0;
|
|
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
|
|
const minutos = Math.abs(saldoMinutos) % 60;
|
|
const positivo = saldoMinutos >= 0;
|
|
|
|
return {
|
|
...registro,
|
|
funcionario: funcionario
|
|
? {
|
|
nome: funcionario.nome,
|
|
matricula: funcionario.matricula,
|
|
descricaoCargo: funcionario.descricaoCargo,
|
|
}
|
|
: null,
|
|
saldoDiario: {
|
|
saldoMinutos,
|
|
horas,
|
|
minutos,
|
|
positivo,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém estatísticas de pontos (para gráficos)
|
|
*/
|
|
export const obterEstatisticas = query({
|
|
args: {
|
|
dataInicio: v.string(), // YYYY-MM-DD
|
|
dataFim: v.string(), // YYYY-MM-DD
|
|
funcionarioId: v.optional(v.id('funcionarios')),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
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,
|
|
};
|
|
}
|
|
|
|
// TODO: Verificar permissão (RH ou TI)
|
|
|
|
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) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
const registro = await ctx.db.get(args.registroId);
|
|
if (!registro) {
|
|
throw new Error('Registro não encontrado');
|
|
}
|
|
|
|
// Verificar se o usuário tem permissão (próprio registro ou RH/TI)
|
|
if (registro.funcionarioId !== usuario.funcionarioId) {
|
|
// TODO: Verificar se é RH ou TI
|
|
// Por enquanto, permitir
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(registro.funcionarioId);
|
|
let simbolo = null;
|
|
if (funcionario) {
|
|
simbolo = await ctx.db.get(funcionario.simboloId);
|
|
}
|
|
|
|
// Obter URL da imagem se existir
|
|
let imagemUrl = null;
|
|
if (registro.imagemId) {
|
|
imagemUrl = await ctx.storage.getUrl(registro.imagemId);
|
|
}
|
|
|
|
return {
|
|
...registro,
|
|
imagemUrl,
|
|
funcionario: funcionario
|
|
? {
|
|
nome: funcionario.nome,
|
|
matricula: funcionario.matricula,
|
|
descricaoCargo: funcionario.descricaoCargo,
|
|
simbolo: simbolo
|
|
? {
|
|
nome: simbolo.nome,
|
|
tipo: simbolo.tipo,
|
|
}
|
|
: null,
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Calcula carga horária diária esperada em minutos
|
|
*/
|
|
function calcularCargaHorariaDiaria(config: {
|
|
horarioEntrada: string;
|
|
horarioSaidaAlmoco: string;
|
|
horarioRetornoAlmoco: string;
|
|
horarioSaida: string;
|
|
}): number {
|
|
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
|
|
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
|
|
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
|
|
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
|
|
|
|
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
|
|
const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
|
|
const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
|
|
const minutosSaida = horaSaida * 60 + minutoSaida;
|
|
|
|
// Calcular horas trabalhadas: (saída almoço - entrada) + (saída - retorno almoço)
|
|
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
|
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
|
|
|
return horasManha + horasTarde;
|
|
}
|
|
|
|
/**
|
|
* Calcula horas trabalhadas do dia baseado nos registros
|
|
*/
|
|
function calcularHorasTrabalhadas(registros: Array<{
|
|
tipo: string;
|
|
hora: number;
|
|
minuto: number;
|
|
}>): number {
|
|
// Ordenar registros por timestamp
|
|
const registrosOrdenados = [...registros].sort((a, b) => {
|
|
const minutosA = a.hora * 60 + a.minuto;
|
|
const minutosB = b.hora * 60 + b.minuto;
|
|
return minutosA - minutosB;
|
|
});
|
|
|
|
let horasTrabalhadas = 0;
|
|
|
|
// Procurar entrada e saída
|
|
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
|
|
const saida = registrosOrdenados.find((r) => r.tipo === 'saida');
|
|
|
|
if (entrada && saida) {
|
|
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
|
const minutosSaida = saida.hora * 60 + saida.minuto;
|
|
|
|
// Procurar saída e retorno do almoço
|
|
const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco');
|
|
const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco');
|
|
|
|
if (saidaAlmoco && retornoAlmoco) {
|
|
// Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço)
|
|
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
|
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
|
|
|
|
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
|
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
|
horasTrabalhadas = horasManha + horasTarde;
|
|
} else {
|
|
// Sem intervalo de almoço registrado: saída - entrada
|
|
horasTrabalhadas = minutosSaida - minutosEntrada;
|
|
}
|
|
}
|
|
|
|
return horasTrabalhadas;
|
|
}
|
|
|
|
/**
|
|
* Atualiza ou cria registro de banco de horas para o dia
|
|
*/
|
|
async function atualizarBancoHoras(
|
|
ctx: MutationCtx,
|
|
funcionarioId: Id<'funcionarios'>,
|
|
data: string,
|
|
config: {
|
|
horarioEntrada: string;
|
|
horarioSaidaAlmoco: string;
|
|
horarioRetornoAlmoco: string;
|
|
horarioSaida: string;
|
|
}
|
|
): Promise<void> {
|
|
// Buscar todos os registros do dia
|
|
const registrosDoDia = await ctx.db
|
|
.query('registrosPonto')
|
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
|
.collect();
|
|
|
|
// Calcular carga horária esperada
|
|
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
|
|
|
|
// Calcular horas trabalhadas
|
|
const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia);
|
|
|
|
// Calcular saldo (positivo = horas extras, negativo = déficit)
|
|
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
|
|
|
|
// Buscar banco de horas existente
|
|
const bancoHorasExistente = await ctx.db
|
|
.query('bancoHoras')
|
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
|
.first();
|
|
|
|
const registrosPontoIds = registrosDoDia.map((r) => r._id);
|
|
|
|
if (bancoHorasExistente) {
|
|
// Atualizar existente
|
|
await ctx.db.patch(bancoHorasExistente._id, {
|
|
cargaHorariaDiaria,
|
|
horasTrabalhadas,
|
|
saldoMinutos,
|
|
registrosPontoIds,
|
|
calculadoEm: Date.now(),
|
|
});
|
|
} else {
|
|
// Criar novo
|
|
await ctx.db.insert('bancoHoras', {
|
|
funcionarioId,
|
|
data,
|
|
cargaHorariaDiaria,
|
|
horasTrabalhadas,
|
|
saldoMinutos,
|
|
registrosPontoIds,
|
|
calculadoEm: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtém histórico e saldo do dia
|
|
*/
|
|
export const obterHistoricoESaldoDia = query({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
data: v.string(), // YYYY-MM-DD
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario || !usuario.funcionarioId) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
// Verificar se é o próprio funcionário ou tem permissão
|
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
|
// TODO: Verificar permissão de RH
|
|
}
|
|
|
|
// Buscar registros do dia
|
|
const registros = await ctx.db
|
|
.query('registrosPonto')
|
|
.withIndex('by_funcionario_data', (q) =>
|
|
q.eq('funcionarioId', args.funcionarioId).eq('data', args.data)
|
|
)
|
|
.order('asc')
|
|
.collect();
|
|
|
|
// Buscar configuração de ponto
|
|
const config = await ctx.db
|
|
.query('configuracaoPonto')
|
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
.first();
|
|
|
|
if (!config) {
|
|
return {
|
|
registros: [],
|
|
cargaHorariaDiaria: 0,
|
|
horasTrabalhadas: 0,
|
|
saldoMinutos: 0,
|
|
};
|
|
}
|
|
|
|
// Calcular valores
|
|
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
|
|
const horasTrabalhadas = calcularHorasTrabalhadas(registros);
|
|
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
|
|
|
|
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
|
|
const minutos = Math.abs(saldoMinutos) % 60;
|
|
const positivo = saldoMinutos >= 0;
|
|
|
|
return {
|
|
registros,
|
|
cargaHorariaDiaria,
|
|
horasTrabalhadas,
|
|
saldoMinutos,
|
|
saldoFormatado: {
|
|
horas,
|
|
minutos,
|
|
positivo,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém banco de horas acumulado do funcionário
|
|
*/
|
|
export const obterBancoHorasFuncionario = query({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
// Verificar se é o próprio funcionário ou tem permissão
|
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
|
// TODO: Verificar permissão de RH
|
|
}
|
|
|
|
// Buscar todos os registros de banco de horas do funcionário
|
|
const bancosHoras = await ctx.db
|
|
.query('bancoHoras')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.order('desc')
|
|
.collect();
|
|
|
|
// Calcular saldo acumulado
|
|
const saldoAcumuladoMinutos = bancosHoras.reduce((acc, bh) => acc + bh.saldoMinutos, 0);
|
|
|
|
return {
|
|
bancosHoras,
|
|
saldoAcumuladoMinutos,
|
|
totalDias: bancosHoras.length,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Helper: Verificar se usuário é gestor do funcionário
|
|
*/
|
|
async function verificarGestorDoFuncionario(
|
|
ctx: QueryCtx | MutationCtx,
|
|
gestorId: Id<'usuarios'>,
|
|
funcionarioId: Id<'funcionarios'>
|
|
): Promise<boolean> {
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
if (!membroTime) return false;
|
|
|
|
const time = await ctx.db.get(membroTime.timeId);
|
|
if (!time) return false;
|
|
|
|
return time.gestorId === gestorId;
|
|
}
|
|
|
|
/**
|
|
* Edita um registro de ponto (homologação pelo gestor)
|
|
*/
|
|
export const editarRegistroPonto = mutation({
|
|
args: {
|
|
registroId: v.id('registrosPonto'),
|
|
horaNova: v.number(),
|
|
minutoNova: v.number(),
|
|
motivoId: v.optional(v.string()),
|
|
motivoTipo: v.optional(v.string()),
|
|
motivoDescricao: v.optional(v.string()),
|
|
observacoes: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
// Buscar registro
|
|
const registro = await ctx.db.get(args.registroId);
|
|
if (!registro) {
|
|
throw new Error('Registro não encontrado');
|
|
}
|
|
|
|
// Verificar se é gestor do funcionário
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, registro.funcionarioId);
|
|
if (!isGestor) {
|
|
throw new Error('Você não tem permissão para editar este registro');
|
|
}
|
|
|
|
// Salvar dados anteriores
|
|
const horaAnterior = registro.hora;
|
|
const minutoAnterior = registro.minuto;
|
|
|
|
// Atualizar registro
|
|
await ctx.db.patch(args.registroId, {
|
|
hora: args.horaNova,
|
|
minuto: args.minutoNova,
|
|
editadoPorGestor: true,
|
|
});
|
|
|
|
// Criar registro de homologação
|
|
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
|
|
registroId: args.registroId,
|
|
funcionarioId: registro.funcionarioId,
|
|
gestorId: usuario._id,
|
|
horaAnterior,
|
|
minutoAnterior,
|
|
horaNova: args.horaNova,
|
|
minutoNova: args.minutoNova,
|
|
motivoId: args.motivoId,
|
|
motivoTipo: args.motivoTipo,
|
|
motivoDescricao: args.motivoDescricao,
|
|
observacoes: args.observacoes,
|
|
criadoEm: Date.now(),
|
|
});
|
|
|
|
// Atualizar registro com ID da homologação
|
|
await ctx.db.patch(args.registroId, {
|
|
homologacaoId,
|
|
});
|
|
|
|
// Recalcular banco de horas do dia
|
|
const config = await ctx.db
|
|
.query('configuracaoPonto')
|
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
.first();
|
|
|
|
if (config) {
|
|
await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config);
|
|
}
|
|
|
|
return { success: true, homologacaoId };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Ajusta banco de horas (compensar, abonar ou descontar)
|
|
*/
|
|
export const ajustarBancoHoras = mutation({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
tipoAjuste: v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')),
|
|
periodoDias: v.number(),
|
|
periodoHoras: v.number(),
|
|
periodoMinutos: v.number(),
|
|
motivoId: v.optional(v.string()),
|
|
motivoTipo: v.optional(v.string()),
|
|
motivoDescricao: v.optional(v.string()),
|
|
observacoes: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
// Verificar se é gestor do funcionário
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId);
|
|
if (!isGestor) {
|
|
throw new Error('Você não tem permissão para ajustar banco de horas deste funcionário');
|
|
}
|
|
|
|
// Calcular ajuste em minutos
|
|
const ajusteMinutos =
|
|
args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos;
|
|
|
|
// Aplicar sinal baseado no tipo de ajuste
|
|
let ajusteFinal = ajusteMinutos;
|
|
if (args.tipoAjuste === 'descontar') {
|
|
ajusteFinal = -ajusteMinutos;
|
|
}
|
|
|
|
// Buscar banco de horas mais recente ou criar um registro de ajuste
|
|
const hoje = new Date().toISOString().split('T')[0]!;
|
|
const bancoHorasAtual = await ctx.db
|
|
.query('bancoHoras')
|
|
.withIndex('by_funcionario_data', (q) =>
|
|
q.eq('funcionarioId', args.funcionarioId).eq('data', hoje)
|
|
)
|
|
.first();
|
|
|
|
if (bancoHorasAtual) {
|
|
// Atualizar saldo do dia atual
|
|
await ctx.db.patch(bancoHorasAtual._id, {
|
|
saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal,
|
|
});
|
|
} else {
|
|
// Criar novo registro de banco de horas para o ajuste
|
|
const config = await ctx.db
|
|
.query('configuracaoPonto')
|
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
.first();
|
|
|
|
if (!config) {
|
|
throw new Error('Configuração de ponto não encontrada');
|
|
}
|
|
|
|
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
|
|
|
|
await ctx.db.insert('bancoHoras', {
|
|
funcionarioId: args.funcionarioId,
|
|
data: hoje,
|
|
cargaHorariaDiaria,
|
|
horasTrabalhadas: 0,
|
|
saldoMinutos: ajusteFinal,
|
|
registrosPontoIds: [],
|
|
calculadoEm: Date.now(),
|
|
});
|
|
}
|
|
|
|
// Criar registro de homologação
|
|
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
|
|
funcionarioId: args.funcionarioId,
|
|
gestorId: usuario._id,
|
|
motivoId: args.motivoId,
|
|
motivoTipo: args.motivoTipo,
|
|
motivoDescricao: args.motivoDescricao,
|
|
observacoes: args.observacoes,
|
|
tipoAjuste: args.tipoAjuste,
|
|
periodoDias: args.periodoDias,
|
|
periodoHoras: args.periodoHoras,
|
|
periodoMinutos: args.periodoMinutos,
|
|
ajusteMinutos: ajusteFinal,
|
|
criadoEm: Date.now(),
|
|
});
|
|
|
|
return { success: true, homologacaoId, ajusteMinutos: ajusteFinal };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Lista homologações de um funcionário ou time
|
|
*/
|
|
export const listarHomologacoes = query({
|
|
args: {
|
|
funcionarioId: v.optional(v.id('funcionarios')),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
let homologacoes;
|
|
|
|
if (args.funcionarioId) {
|
|
// Verificar se é gestor do funcionário ou o próprio funcionário
|
|
const funcionarioId = args.funcionarioId; // Garantir que não é undefined
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId);
|
|
const isProprioFuncionario = usuario.funcionarioId === funcionarioId;
|
|
|
|
if (!isGestor && !isProprioFuncionario) {
|
|
throw new Error('Você não tem permissão para ver estas homologações');
|
|
}
|
|
|
|
homologacoes = await ctx.db
|
|
.query('homologacoesPonto')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
|
|
.order('desc')
|
|
.collect();
|
|
} else {
|
|
// Listar homologações do gestor
|
|
homologacoes = await ctx.db
|
|
.query('homologacoesPonto')
|
|
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
|
|
.order('desc')
|
|
.collect();
|
|
}
|
|
|
|
// Buscar informações adicionais
|
|
const homologacoesComDetalhes = await Promise.all(
|
|
homologacoes.map(async (h) => {
|
|
const funcionario = await ctx.db.get(h.funcionarioId);
|
|
const gestor = await ctx.db.get(h.gestorId);
|
|
const registro = h.registroId ? await ctx.db.get(h.registroId) : null;
|
|
|
|
return {
|
|
...h,
|
|
funcionario: funcionario
|
|
? {
|
|
nome: funcionario.nome,
|
|
matricula: funcionario.matricula,
|
|
}
|
|
: null,
|
|
gestor: gestor
|
|
? {
|
|
nome: gestor.nome,
|
|
}
|
|
: null,
|
|
registro: registro
|
|
? {
|
|
data: registro.data,
|
|
tipo: registro.tipo,
|
|
}
|
|
: null,
|
|
};
|
|
})
|
|
);
|
|
|
|
return homologacoesComDetalhes;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Exclui uma homologação (apenas para gestores)
|
|
*/
|
|
export const excluirHomologacao = mutation({
|
|
args: {
|
|
homologacaoId: v.id('homologacoesPonto'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
const homologacao = await ctx.db.get(args.homologacaoId);
|
|
if (!homologacao) {
|
|
throw new Error('Homologação não encontrada');
|
|
}
|
|
|
|
// Verificar se é gestor do funcionário
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, homologacao.funcionarioId);
|
|
if (!isGestor && homologacao.gestorId !== usuario._id) {
|
|
throw new Error('Você não tem permissão para excluir esta homologação');
|
|
}
|
|
|
|
// Se a homologação estiver vinculada a um registro, remover a referência
|
|
if (homologacao.registroId) {
|
|
const registro = await ctx.db.get(homologacao.registroId);
|
|
if (registro && registro.homologacaoId === args.homologacaoId) {
|
|
await ctx.db.patch(homologacao.registroId, {
|
|
homologacaoId: undefined,
|
|
editadoPorGestor: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Excluir homologação
|
|
await ctx.db.delete(args.homologacaoId);
|
|
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Obtém opções de motivos de atestados/declarações
|
|
*/
|
|
export const obterMotivosAtestados = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
// Buscar tipos de atestados e declarações
|
|
const atestados = await ctx.db.query('atestados').collect();
|
|
const tiposUnicos = new Set<string>();
|
|
|
|
atestados.forEach((a) => {
|
|
if (a.cid) tiposUnicos.add(`CID: ${a.cid}`);
|
|
if (a.observacoes) tiposUnicos.add(a.observacoes);
|
|
});
|
|
|
|
return {
|
|
tipos: Array.from(tiposUnicos),
|
|
opcoesPadrao: [
|
|
'Atestado Médico',
|
|
'Declaração',
|
|
'Ajuste Administrativo',
|
|
'Compensação de Horas',
|
|
'Abono',
|
|
'Desconto em Folha',
|
|
],
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Cria uma dispensa de registro de ponto
|
|
*/
|
|
export const criarDispensaRegistro = mutation({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
dataInicio: v.string(), // YYYY-MM-DD
|
|
horaInicio: v.number(),
|
|
minutoInicio: v.number(),
|
|
dataFim: v.string(), // YYYY-MM-DD
|
|
horaFim: v.number(),
|
|
minutoFim: v.number(),
|
|
motivo: v.string(),
|
|
isento: v.boolean(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
// Verificar se é gestor do funcionário
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId);
|
|
if (!isGestor) {
|
|
throw new Error('Você não tem permissão para criar dispensa para este funcionário');
|
|
}
|
|
|
|
// Validar datas
|
|
const dataInicioObj = new Date(args.dataInicio);
|
|
const dataFimObj = new Date(args.dataFim);
|
|
|
|
if (dataFimObj < dataInicioObj) {
|
|
throw new Error('Data fim deve ser maior ou igual à data início');
|
|
}
|
|
|
|
// Criar dispensa
|
|
const dispensaId = await ctx.db.insert('dispensasRegistro', {
|
|
funcionarioId: args.funcionarioId,
|
|
gestorId: usuario._id,
|
|
dataInicio: args.dataInicio,
|
|
horaInicio: args.horaInicio,
|
|
minutoInicio: args.minutoInicio,
|
|
dataFim: args.dataFim,
|
|
horaFim: args.horaFim,
|
|
minutoFim: args.minutoFim,
|
|
motivo: args.motivo,
|
|
isento: args.isento,
|
|
ativo: true,
|
|
criadoEm: Date.now(),
|
|
});
|
|
|
|
return { success: true, dispensaId };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Remove uma dispensa de registro (cancela)
|
|
*/
|
|
export const removerDispensaRegistro = mutation({
|
|
args: {
|
|
dispensaId: v.id('dispensasRegistro'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
const dispensa = await ctx.db.get(args.dispensaId);
|
|
if (!dispensa) {
|
|
throw new Error('Dispensa não encontrada');
|
|
}
|
|
|
|
// Verificar se é gestor do funcionário
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, dispensa.funcionarioId);
|
|
if (!isGestor && dispensa.gestorId !== usuario._id) {
|
|
throw new Error('Você não tem permissão para remover esta dispensa');
|
|
}
|
|
|
|
// Desativar dispensa
|
|
await ctx.db.patch(args.dispensaId, {
|
|
ativo: false,
|
|
});
|
|
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Lista dispensas de registro
|
|
*/
|
|
export const listarDispensas = query({
|
|
args: {
|
|
funcionarioId: v.optional(v.id('funcionarios')),
|
|
apenasAtivas: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
|
|
let dispensas;
|
|
|
|
if (args.funcionarioId) {
|
|
// Verificar se é gestor do funcionário ou o próprio funcionário
|
|
const funcionarioId = args.funcionarioId; // Garantir que não é undefined
|
|
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId);
|
|
const isProprioFuncionario = usuario.funcionarioId === funcionarioId;
|
|
|
|
if (!isGestor && !isProprioFuncionario) {
|
|
throw new Error('Você não tem permissão para ver estas dispensas');
|
|
}
|
|
|
|
dispensas = await ctx.db
|
|
.query('dispensasRegistro')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
|
|
.filter((q) => {
|
|
if (args.apenasAtivas !== undefined && args.apenasAtivas) {
|
|
return q.eq(q.field('ativo'), true);
|
|
}
|
|
return true; // Retornar todas se apenasAtivas não for especificado
|
|
})
|
|
.order('desc')
|
|
.collect();
|
|
} else {
|
|
// Listar dispensas do gestor
|
|
dispensas = await ctx.db
|
|
.query('dispensasRegistro')
|
|
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
|
|
.filter((q) => {
|
|
if (args.apenasAtivas !== undefined && args.apenasAtivas) {
|
|
return q.eq(q.field('ativo'), true);
|
|
}
|
|
return true; // Retornar todas se apenasAtivas não for especificado
|
|
})
|
|
.order('desc')
|
|
.collect();
|
|
}
|
|
|
|
// Buscar informações adicionais
|
|
const dispensasComDetalhes = await Promise.all(
|
|
dispensas.map(async (d) => {
|
|
const funcionario = await ctx.db.get(d.funcionarioId);
|
|
const gestor = await ctx.db.get(d.gestorId);
|
|
|
|
// Verificar se expirou (se não for isento)
|
|
let expirada = false;
|
|
if (!d.isento) {
|
|
const agora = new Date();
|
|
const dataFimTimestamp = new Date(
|
|
`${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
expirada = agora.getTime() > dataFimTimestamp;
|
|
}
|
|
|
|
return {
|
|
...d,
|
|
funcionario: funcionario
|
|
? {
|
|
nome: funcionario.nome,
|
|
matricula: funcionario.matricula,
|
|
}
|
|
: null,
|
|
gestor: gestor
|
|
? {
|
|
nome: gestor.nome,
|
|
}
|
|
: null,
|
|
expirada,
|
|
};
|
|
})
|
|
);
|
|
|
|
return dispensasComDetalhes;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica
|
|
*/
|
|
export const verificarDispensaAtiva = query({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
data: v.string(), // YYYY-MM-DD
|
|
hora: v.optional(v.number()),
|
|
minuto: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const dispensas = await ctx.db
|
|
.query('dispensasRegistro')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.collect();
|
|
|
|
const dataConsulta = new Date(args.data);
|
|
|
|
for (const dispensa of dispensas) {
|
|
// Se for isento, sempre está dispensado
|
|
if (dispensa.isento) {
|
|
return {
|
|
dispensado: true,
|
|
dispensa,
|
|
motivo: 'Isento de registro (caso excepcional)',
|
|
};
|
|
}
|
|
|
|
// Verificar se está no período
|
|
const dataInicio = new Date(dispensa.dataInicio);
|
|
const dataFim = new Date(dispensa.dataFim);
|
|
|
|
// Se a data está dentro do período
|
|
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
|
|
// Se hora e minuto foram fornecidos, verificar também
|
|
if (args.hora !== undefined && args.minuto !== undefined) {
|
|
const timestampConsulta = new Date(
|
|
`${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
const timestampInicio = new Date(
|
|
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
const timestampFim = new Date(
|
|
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
|
|
).getTime();
|
|
|
|
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
|
|
return {
|
|
dispensado: true,
|
|
dispensa,
|
|
motivo: dispensa.motivo,
|
|
};
|
|
}
|
|
} else {
|
|
// Apenas verificar data
|
|
return {
|
|
dispensado: true,
|
|
dispensa,
|
|
motivo: dispensa.motivo,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
dispensado: false,
|
|
dispensa: null,
|
|
motivo: null,
|
|
};
|
|
},
|
|
});
|
|
|