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:
442
packages/backend/convex/pontos.ts
Normal file
442
packages/backend/convex/pontos.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user