feat: implement date parsing utility across absence management components for improved date handling and consistency
This commit is contained in:
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -83,6 +83,7 @@ import type * as templatesMensagens from "../templatesMensagens.js";
|
||||
import type * as times from "../times.js";
|
||||
import type * as usuarios from "../usuarios.js";
|
||||
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
|
||||
import type * as utils_datas from "../utils/datas.js";
|
||||
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
|
||||
import type * as utils_getClientIP from "../utils/getClientIP.js";
|
||||
import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js";
|
||||
@@ -170,6 +171,7 @@ declare const fullApi: ApiFromModules<{
|
||||
times: typeof times;
|
||||
usuarios: typeof usuarios;
|
||||
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
|
||||
"utils/datas": typeof utils_datas;
|
||||
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
|
||||
"utils/getClientIP": typeof utils_getClientIP;
|
||||
"utils/scanEmailSenders": typeof utils_scanEmailSenders;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mutation, query } from './_generated/server';
|
||||
import type { QueryCtx, MutationCtx } from './_generated/server';
|
||||
import { internal, api } from './_generated/api';
|
||||
import { Id, Doc } from './_generated/dataModel';
|
||||
import { parseLocalDate, formatarDataBR } from './utils/datas';
|
||||
|
||||
// Query: Listar todas as solicitações (para RH)
|
||||
export const listarTodas = query({
|
||||
@@ -257,10 +258,10 @@ function verificarSobreposicao(
|
||||
inicio2: string,
|
||||
fim2: string
|
||||
): boolean {
|
||||
const d1Inicio = new Date(inicio1);
|
||||
const d1Fim = new Date(fim1);
|
||||
const d2Inicio = new Date(inicio2);
|
||||
const d2Fim = new Date(fim2);
|
||||
const d1Inicio = parseLocalDate(inicio1);
|
||||
const d1Fim = parseLocalDate(fim1);
|
||||
const d2Inicio = parseLocalDate(inicio2);
|
||||
const d2Fim = parseLocalDate(fim2);
|
||||
|
||||
return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
|
||||
}
|
||||
@@ -299,12 +300,14 @@ export const criarSolicitacao = mutation({
|
||||
throw new Error('O motivo deve ter no mínimo 10 caracteres');
|
||||
}
|
||||
|
||||
const dataInicio = new Date(args.dataInicio);
|
||||
const dataFim = new Date(args.dataFim);
|
||||
const dataInicio = parseLocalDate(args.dataInicio);
|
||||
const dataFim = parseLocalDate(args.dataFim);
|
||||
|
||||
// Criar data de hoje em UTC para comparação
|
||||
const hoje = new Date();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const hojeUTC = new Date(Date.UTC(hoje.getUTCFullYear(), hoje.getUTCMonth(), hoje.getUTCDate(), 0, 0, 0, 0));
|
||||
|
||||
if (dataInicio < hoje) {
|
||||
if (dataInicio < hojeUTC) {
|
||||
throw new Error('A data de início não pode ser no passado');
|
||||
}
|
||||
|
||||
@@ -351,7 +354,7 @@ export const criarSolicitacao = mutation({
|
||||
solicitacaoAusenciaId: solicitacaoId,
|
||||
tipo: 'nova_solicitacao',
|
||||
lida: false,
|
||||
mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}`
|
||||
mensagem: `${funcionario.nome} solicitou uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}`
|
||||
});
|
||||
|
||||
// Buscar usuário do gestor para enviar email e chat
|
||||
@@ -377,8 +380,8 @@ export const criarSolicitacao = mutation({
|
||||
variaveis: {
|
||||
gestorNome: gestorUsuario.nome,
|
||||
funcionarioNome: funcionario.nome,
|
||||
dataInicio: new Date(args.dataInicio).toLocaleDateString('pt-BR'),
|
||||
dataFim: new Date(args.dataFim).toLocaleDateString('pt-BR'),
|
||||
dataInicio: formatarDataBR(args.dataInicio),
|
||||
dataFim: formatarDataBR(args.dataFim),
|
||||
motivo: args.motivo,
|
||||
urlSistema
|
||||
},
|
||||
@@ -397,7 +400,7 @@ export const criarSolicitacao = mutation({
|
||||
corpo: `<p>Olá ${gestorUsuario.nome},</p>
|
||||
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
|
||||
<ul>
|
||||
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}</li>
|
||||
<li><strong>Período:</strong> ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}</li>
|
||||
<li><strong>Motivo:</strong> ${args.motivo}</li>
|
||||
</ul>
|
||||
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
|
||||
@@ -437,7 +440,7 @@ export const criarSolicitacao = mutation({
|
||||
conversaId,
|
||||
remetenteId: funcionarioUsuario._id,
|
||||
tipo: 'texto',
|
||||
conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivo}`,
|
||||
conteudo: `Solicitei uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}. Motivo: ${args.motivo}`,
|
||||
enviadaEm: Date.now()
|
||||
});
|
||||
}
|
||||
@@ -499,7 +502,7 @@ export const aprovar = mutation({
|
||||
solicitacaoAusenciaId: args.solicitacaoId,
|
||||
tipo: 'aprovado',
|
||||
lida: false,
|
||||
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi aprovada!`
|
||||
mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi aprovada!`
|
||||
});
|
||||
|
||||
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||
@@ -520,8 +523,8 @@ export const aprovar = mutation({
|
||||
variaveis: {
|
||||
funcionarioNome: funcionarioUsuario.nome,
|
||||
gestorNome: gestorUsuario.nome,
|
||||
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
|
||||
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
|
||||
dataInicio: formatarDataBR(solicitacao.dataInicio),
|
||||
dataFim: formatarDataBR(solicitacao.dataFim),
|
||||
motivo: solicitacao.motivo,
|
||||
urlSistema
|
||||
},
|
||||
@@ -540,7 +543,7 @@ export const aprovar = mutation({
|
||||
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
||||
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
||||
<ul>
|
||||
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</li>
|
||||
<li><strong>Período:</strong> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
|
||||
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
||||
</ul>`,
|
||||
enviadoPor: args.gestorId
|
||||
@@ -579,7 +582,7 @@ export const aprovar = mutation({
|
||||
conversaId,
|
||||
remetenteId: args.gestorId,
|
||||
tipo: 'texto',
|
||||
conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}.`,
|
||||
conteudo: `Aprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}.`,
|
||||
enviadaEm: Date.now()
|
||||
});
|
||||
}
|
||||
@@ -643,7 +646,7 @@ export const reprovar = mutation({
|
||||
solicitacaoAusenciaId: args.solicitacaoId,
|
||||
tipo: 'reprovado',
|
||||
lida: false,
|
||||
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi reprovada. Motivo: ${args.motivoReprovacao}`
|
||||
mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi reprovada. Motivo: ${args.motivoReprovacao}`
|
||||
});
|
||||
|
||||
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||
@@ -664,8 +667,8 @@ export const reprovar = mutation({
|
||||
variaveis: {
|
||||
funcionarioNome: funcionarioUsuario.nome,
|
||||
gestorNome: gestorUsuario.nome,
|
||||
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
|
||||
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
|
||||
dataInicio: formatarDataBR(solicitacao.dataInicio),
|
||||
dataFim: formatarDataBR(solicitacao.dataFim),
|
||||
motivo: solicitacao.motivo,
|
||||
motivoReprovacao: args.motivoReprovacao,
|
||||
urlSistema
|
||||
@@ -685,7 +688,7 @@ export const reprovar = mutation({
|
||||
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
||||
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
||||
<ul>
|
||||
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</li>
|
||||
<li><strong>Período:</strong> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
|
||||
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
||||
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
|
||||
</ul>`,
|
||||
@@ -725,7 +728,7 @@ export const reprovar = mutation({
|
||||
conversaId,
|
||||
remetenteId: args.gestorId,
|
||||
tipo: 'texto',
|
||||
conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivoReprovacao}`,
|
||||
conteudo: `Reprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}. Motivo: ${args.motivoReprovacao}`,
|
||||
enviadaEm: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,4 +50,12 @@ crons.interval(
|
||||
{}
|
||||
);
|
||||
|
||||
// Verificar e enviar notificações de alertas de banco de horas diariamente às 8h
|
||||
crons.interval(
|
||||
'verificar-alertas-banco-horas',
|
||||
{ hours: 24 },
|
||||
internal.pontos.enviarNotificacoesAlertasBancoHoras,
|
||||
{}
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { v } from 'convex/values';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import { mutation, query, internalMutation } from './_generated/server';
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
|
||||
import { internal } from './_generated/api';
|
||||
|
||||
/**
|
||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||
@@ -350,6 +351,91 @@ function calcularStatusPonto(
|
||||
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
|
||||
*/
|
||||
@@ -566,6 +652,18 @@ export const registrarPonto = mutation({
|
||||
// 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) {
|
||||
@@ -1212,6 +1310,7 @@ function calcularCargaHorariaDiaria(config: {
|
||||
|
||||
/**
|
||||
* Calcula horas trabalhadas do dia baseado nos registros
|
||||
* Trata casos incompletos de forma mais robusta
|
||||
*/
|
||||
function calcularHorasTrabalhadas(
|
||||
registros: Array<{
|
||||
@@ -1220,6 +1319,10 @@ function calcularHorasTrabalhadas(
|
||||
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;
|
||||
@@ -1227,35 +1330,78 @@ function calcularHorasTrabalhadas(
|
||||
return minutosA - minutosB;
|
||||
});
|
||||
|
||||
let horasTrabalhadas = 0;
|
||||
|
||||
// Procurar entrada e saída
|
||||
// Procurar registros principais
|
||||
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
|
||||
const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco');
|
||||
const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco');
|
||||
const saida = registrosOrdenados.find((r) => r.tipo === 'saida');
|
||||
|
||||
// Caso 1: Tem entrada e saída completas
|
||||
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');
|
||||
|
||||
// Caso 1.1: Tem intervalo de almoço completo (saída almoço + retorno almoço)
|
||||
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;
|
||||
// Validar ordem lógica
|
||||
if (
|
||||
minutosSaidaAlmoco > minutosEntrada &&
|
||||
minutosRetornoAlmoco > minutosSaidaAlmoco &&
|
||||
minutosSaida > minutosRetornoAlmoco
|
||||
) {
|
||||
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
||||
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
||||
return horasManha + horasTarde;
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 1.2: Tem apenas saída almoço (sem retorno) - considerar apenas manhã
|
||||
if (saidaAlmoco && !retornoAlmoco) {
|
||||
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
||||
if (minutosSaidaAlmoco > minutosEntrada) {
|
||||
return minutosSaidaAlmoco - minutosEntrada;
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 1.3: Tem apenas retorno almoço (sem saída) - considerar apenas tarde
|
||||
if (!saidaAlmoco && retornoAlmoco) {
|
||||
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
|
||||
if (minutosSaida > minutosRetornoAlmoco) {
|
||||
return minutosSaida - minutosRetornoAlmoco;
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 1.4: Sem intervalo de almoço registrado - calcular direto
|
||||
if (!saidaAlmoco && !retornoAlmoco) {
|
||||
if (minutosSaida > minutosEntrada) {
|
||||
return minutosSaida - minutosEntrada;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return horasTrabalhadas;
|
||||
// Caso 2: Tem apenas entrada (sem saída) - retornar 0 (dia incompleto)
|
||||
if (entrada && !saida) {
|
||||
// Se tiver saída almoço, considerar até a saída almoço
|
||||
if (saidaAlmoco) {
|
||||
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
||||
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
||||
if (minutosSaidaAlmoco > minutosEntrada) {
|
||||
return minutosSaidaAlmoco - minutosEntrada;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Caso 3: Tem apenas saída (sem entrada) - inconsistência, retornar 0
|
||||
if (saida && !entrada) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Caso 4: Não tem entrada nem saída - retornar 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1316,6 +1462,10 @@ async function atualizarBancoHoras(
|
||||
calculadoEm: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// Atualizar banco de horas mensal
|
||||
const mes = data.substring(0, 7); // YYYY-MM
|
||||
await calcularBancoHorasMensal(ctx, funcionarioId, mes);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1433,6 +1583,372 @@ export const obterBancoHorasFuncionario = query({
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async function calcularBancoHorasMensal(
|
||||
ctx: MutationCtx,
|
||||
funcionarioId: Id<'funcionarios'>,
|
||||
mes: string // YYYY-MM
|
||||
): 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)
|
||||
);
|
||||
|
||||
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,
|
||||
atualizadoEm: agora
|
||||
});
|
||||
} else {
|
||||
// Criar novo
|
||||
await ctx.db.insert('bancoHorasMensal', {
|
||||
funcionarioId,
|
||||
mes,
|
||||
saldoInicialMinutos,
|
||||
saldoFinalMinutos,
|
||||
saldoMesMinutos,
|
||||
diasTrabalhados,
|
||||
horasExtras,
|
||||
horasDeficit,
|
||||
calculadoEm: agora,
|
||||
atualizadoEm: agora
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
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
|
||||
}
|
||||
|
||||
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) => {
|
||||
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
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 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
|
||||
*/
|
||||
@@ -1518,7 +2034,7 @@ export const editarRegistroPonto = mutation({
|
||||
homologacaoId
|
||||
});
|
||||
|
||||
// Recalcular banco de horas do dia
|
||||
// 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))
|
||||
@@ -1606,6 +2122,10 @@ export const ajustarBancoHoras = mutation({
|
||||
});
|
||||
}
|
||||
|
||||
// Recalcular banco de horas mensal após ajuste
|
||||
const mes = hoje.substring(0, 7); // YYYY-MM
|
||||
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes);
|
||||
|
||||
// Criar registro de homologação
|
||||
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
|
||||
funcionarioId: args.funcionarioId,
|
||||
@@ -1978,6 +2498,163 @@ export const listarDispensas = query({
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const usuario = await getCurrentUserFunction(ctx);
|
||||
if (!usuario) {
|
||||
throw new Error('Usuário não autenticado');
|
||||
}
|
||||
|
||||
// TODO: Verificar permissão de RH/TI
|
||||
|
||||
// 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);
|
||||
return {
|
||||
...banco,
|
||||
funcionario: funcionario
|
||||
? {
|
||||
nome: funcionario.nome,
|
||||
matricula: funcionario.matricula
|
||||
}
|
||||
: null
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
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 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
|
||||
*/
|
||||
|
||||
@@ -210,6 +210,23 @@ export const pontoTables = {
|
||||
.index('by_funcionario', ['funcionarioId'])
|
||||
.index('by_data', ['data']),
|
||||
|
||||
// Banco de Horas Mensal - Agregação mensal do banco de horas
|
||||
bancoHorasMensal: defineTable({
|
||||
funcionarioId: v.id('funcionarios'),
|
||||
mes: v.string(), // YYYY-MM (ex: "2024-01")
|
||||
saldoInicialMinutos: v.number(), // Saldo acumulado do mês anterior (pode ser negativo)
|
||||
saldoFinalMinutos: v.number(), // Saldo acumulado ao final do mês
|
||||
saldoMesMinutos: v.number(), // Saldo apenas do mês atual (sem acumular)
|
||||
diasTrabalhados: v.number(), // Quantidade de dias com registros no mês
|
||||
horasExtras: v.number(), // Total de minutos positivos do mês
|
||||
horasDeficit: v.number(), // Total de minutos negativos do mês (valor absoluto)
|
||||
calculadoEm: v.number(),
|
||||
atualizadoEm: v.number()
|
||||
})
|
||||
.index('by_funcionario_mes', ['funcionarioId', 'mes'])
|
||||
.index('by_funcionario', ['funcionarioId'])
|
||||
.index('by_mes', ['mes']),
|
||||
|
||||
// Homologações de Ponto - Edições e ajustes realizados pelo gestor
|
||||
homologacoesPonto: defineTable({
|
||||
registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição)
|
||||
|
||||
65
packages/backend/convex/utils/datas.ts
Normal file
65
packages/backend/convex/utils/datas.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Utilitários para manipulação de datas no backend
|
||||
* Resolve problemas de timezone ao trabalhar com datas no formato YYYY-MM-DD
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
|
||||
* No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir
|
||||
* que a data seja interpretada corretamente.
|
||||
*
|
||||
* @param dateString - String no formato YYYY-MM-DD
|
||||
* @returns Date objeto representando a data
|
||||
*
|
||||
* @example
|
||||
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024
|
||||
*/
|
||||
export function parseLocalDate(dateString: string): Date {
|
||||
if (!dateString || typeof dateString !== 'string') {
|
||||
throw new Error('dateString deve ser uma string válida');
|
||||
}
|
||||
|
||||
// Validar formato YYYY-MM-DD
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(dateString)) {
|
||||
throw new Error('dateString deve estar no formato YYYY-MM-DD');
|
||||
}
|
||||
|
||||
// Extrair ano, mês e dia
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
|
||||
// No Convex, criar a data usando UTC para evitar problemas de timezone
|
||||
// Usamos UTC para garantir consistência, mas mantemos a data correta
|
||||
const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
|
||||
|
||||
// Validar se a data é válida
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Data inválida: ${dateString}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
|
||||
*
|
||||
* @param date - Date objeto ou string no formato YYYY-MM-DD
|
||||
* @returns String formatada no formato DD/MM/YYYY
|
||||
*/
|
||||
export function formatarDataBR(date: Date | string): string {
|
||||
let dateObj: Date;
|
||||
|
||||
if (typeof date === 'string') {
|
||||
dateObj = parseLocalDate(date);
|
||||
} else {
|
||||
dateObj = date;
|
||||
}
|
||||
|
||||
// Usar UTC para garantir consistência
|
||||
const day = dateObj.getUTCDate().toString().padStart(2, '0');
|
||||
const month = (dateObj.getUTCMonth() + 1).toString().padStart(2, '0');
|
||||
const year = dateObj.getUTCFullYear();
|
||||
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user