feat: integrate point management features into the dashboard

- Added a new "Meu Ponto" section for users to register their work hours, breaks, and attendance.
- Introduced a "Controle de Ponto" category in the Recursos Humanos section for managing employee time records.
- Enhanced the backend schema to support point registration and configuration settings.
- Updated various components to improve UI consistency and user experience across the dashboard.
This commit is contained in:
2025-11-18 11:44:12 -03:00
parent 52123a33b3
commit f0c6e4468f
22 changed files with 3604 additions and 128 deletions

View File

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