1199 lines
37 KiB
TypeScript
1199 lines
37 KiB
TypeScript
import { v } from 'convex/values';
|
|
import { mutation, query, internalMutation } from './_generated/server';
|
|
import { internal } from './_generated/api';
|
|
import { Id, Doc } from './_generated/dataModel';
|
|
import { verificarLicencaAtiva } from './atestadosLicencas';
|
|
import { getCurrentUserFunction } from './auth';
|
|
import { formatarDataBR } from './utils/datas';
|
|
import { api } from './_generated/api';
|
|
|
|
// Validador para períodos
|
|
const periodoValidator = v.object({
|
|
dataInicio: v.string(),
|
|
dataFim: v.string(),
|
|
diasCorridos: v.number()
|
|
});
|
|
|
|
// Helper: Calcular dias entre duas datas
|
|
function calcularDiasEntreDatas(dataInicio: string, dataFim: string): number {
|
|
const inicio = new Date(dataInicio);
|
|
const fim = new Date(dataFim);
|
|
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
|
return diffDays;
|
|
}
|
|
|
|
// Helper: Agrupar registros de ferias por funcionarioId + anoReferencia
|
|
function agruparPorSolicitacao(registros: Array<Doc<'ferias'>>): Array<{
|
|
funcionarioId: Id<'funcionarios'>;
|
|
anoReferencia: number;
|
|
periodos: Array<Doc<'ferias'>>;
|
|
status: string;
|
|
observacao?: string;
|
|
motivoReprovacao?: string;
|
|
gestorId?: Id<'usuarios'>;
|
|
dataAprovacao?: number;
|
|
dataReprovacao?: number;
|
|
historicoAlteracoes?: Array<{
|
|
data: number;
|
|
usuarioId: Id<'usuarios'>;
|
|
acao: string;
|
|
}>;
|
|
}> {
|
|
const grupos = new Map<string, Array<Doc<'ferias'>>>();
|
|
|
|
for (const registro of registros) {
|
|
const chave = `${registro.funcionarioId}_${registro.anoReferencia}`;
|
|
if (!grupos.has(chave)) {
|
|
grupos.set(chave, []);
|
|
}
|
|
grupos.get(chave)!.push(registro);
|
|
}
|
|
|
|
return Array.from(grupos.entries()).map(([, periodos]) => {
|
|
// Ordenar por data de criação para manter ordem
|
|
periodos.sort((a, b) => a._creationTime - b._creationTime);
|
|
|
|
// Pegar informações da primeira solicitação (todos têm os mesmos campos compartilhados)
|
|
const primeiro = periodos[0];
|
|
|
|
return {
|
|
funcionarioId: primeiro.funcionarioId,
|
|
anoReferencia: primeiro.anoReferencia,
|
|
periodos,
|
|
status: primeiro.status,
|
|
observacao: primeiro.observacao,
|
|
motivoReprovacao: primeiro.motivoReprovacao,
|
|
gestorId: primeiro.gestorId,
|
|
dataAprovacao: primeiro.dataAprovacao,
|
|
dataReprovacao: primeiro.dataReprovacao,
|
|
historicoAlteracoes: primeiro.historicoAlteracoes
|
|
};
|
|
});
|
|
}
|
|
|
|
// Query: Listar TODAS as solicitações (para RH) - períodos individuais
|
|
export const listarTodas = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const todasFerias = await ctx.db.query('ferias').collect();
|
|
|
|
const periodosComDetalhes = await Promise.all(
|
|
todasFerias.map(async (ferias) => {
|
|
const funcionario = await ctx.db.get(ferias.funcionarioId);
|
|
|
|
// Buscar usuário do funcionário para obter fotoPerfilUrl
|
|
let fotoPerfilUrl: string | null = null;
|
|
if (funcionario) {
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
if (usuario?.fotoPerfil) {
|
|
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
|
|
}
|
|
}
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', ferias.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
let time: Doc<'times'> | null = null;
|
|
let gestor: { _id: Id<'usuarios'>; nome: string } | null = null;
|
|
if (membroTime) {
|
|
time = await ctx.db.get(membroTime.timeId);
|
|
// Buscar gestor do time
|
|
if (time?.gestorId) {
|
|
const gestorUsuario = await ctx.db.get(time.gestorId);
|
|
if (gestorUsuario?.funcionarioId) {
|
|
// Buscar funcionário do gestor para obter o nome
|
|
const gestorFuncionario = await ctx.db.get(gestorUsuario.funcionarioId);
|
|
if (gestorFuncionario) {
|
|
gestor = {
|
|
_id: gestorUsuario._id,
|
|
nome: gestorFuncionario.nome
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
...ferias,
|
|
funcionario: funcionario
|
|
? {
|
|
...funcionario,
|
|
fotoPerfilUrl
|
|
}
|
|
: null,
|
|
time,
|
|
gestor
|
|
};
|
|
})
|
|
);
|
|
|
|
return periodosComDetalhes.sort((a, b) => b._creationTime - a._creationTime);
|
|
}
|
|
});
|
|
|
|
// Query: Listar solicitações do funcionário - períodos individuais
|
|
export const listarMinhasSolicitacoes = query({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
_refresh: v.optional(v.number()) // Parâmetro para forçar atualização no frontend
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const todasFerias = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.collect();
|
|
|
|
const funcionario = await ctx.db.get(args.funcionarioId);
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
let time = null;
|
|
if (membroTime) {
|
|
time = await ctx.db.get(membroTime.timeId);
|
|
}
|
|
|
|
// Retornar períodos individuais com detalhes
|
|
return todasFerias
|
|
.map((ferias) => ({
|
|
...ferias,
|
|
funcionario,
|
|
time
|
|
}))
|
|
.sort((a, b) => b._creationTime - a._creationTime);
|
|
}
|
|
});
|
|
|
|
// Query: Listar solicitações dos subordinados (para gestores) - períodos individuais
|
|
export const listarSolicitacoesSubordinados = query({
|
|
args: { gestorId: v.id('usuarios') },
|
|
handler: async (ctx, args) => {
|
|
// Buscar times onde o usuário é gestor
|
|
const timesGestor = await ctx.db
|
|
.query('times')
|
|
.withIndex('by_gestor', (q) => q.eq('gestorId', args.gestorId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.collect();
|
|
|
|
const todasFerias: Array<Doc<'ferias'>> = [];
|
|
|
|
for (const time of timesGestor) {
|
|
// Buscar membros do time
|
|
const membros = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_time_and_ativo', (q) => q.eq('timeId', time._id).eq('ativo', true))
|
|
.collect();
|
|
|
|
// Buscar férias de cada membro
|
|
for (const membro of membros) {
|
|
const ferias = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', membro.funcionarioId))
|
|
.collect();
|
|
|
|
todasFerias.push(...ferias);
|
|
}
|
|
}
|
|
|
|
// Adicionar info do funcionário e time para cada período
|
|
const periodosComDetalhes = await Promise.all(
|
|
todasFerias.map(async (ferias) => {
|
|
const funcionario = await ctx.db.get(ferias.funcionarioId);
|
|
|
|
// Buscar usuário do funcionário para obter fotoPerfilUrl
|
|
let fotoPerfilUrl: string | null = null;
|
|
if (funcionario) {
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
if (usuario?.fotoPerfil) {
|
|
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
|
|
}
|
|
}
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', ferias.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
let time = null;
|
|
if (membroTime) {
|
|
time = await ctx.db.get(membroTime.timeId);
|
|
}
|
|
|
|
return {
|
|
...ferias,
|
|
funcionario: funcionario
|
|
? {
|
|
...funcionario,
|
|
fotoPerfilUrl
|
|
}
|
|
: null,
|
|
time
|
|
};
|
|
})
|
|
);
|
|
|
|
return periodosComDetalhes.sort((a, b) => b._creationTime - a._creationTime);
|
|
}
|
|
});
|
|
|
|
// Query: Obter detalhes de um período individual
|
|
export const obterDetalhes = query({
|
|
args: {
|
|
feriasId: v.id('ferias')
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const ferias = await ctx.db.get(args.feriasId);
|
|
|
|
if (!ferias) return null;
|
|
|
|
const funcionario = await ctx.db.get(ferias.funcionarioId);
|
|
|
|
// Buscar usuário do funcionário para obter fotoPerfilUrl
|
|
let fotoPerfilUrl: string | null = null;
|
|
if (funcionario) {
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
if (usuario?.fotoPerfil) {
|
|
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
|
|
}
|
|
}
|
|
|
|
let gestor = null;
|
|
if (ferias.gestorId) {
|
|
gestor = await ctx.db.get(ferias.gestorId);
|
|
}
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', ferias.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
let time = null;
|
|
if (membroTime) {
|
|
time = await ctx.db.get(membroTime.timeId);
|
|
}
|
|
|
|
// Enriquecer histórico com nomes dos usuários
|
|
let historicoComUsuarios = ferias.historicoAlteracoes;
|
|
if (historicoComUsuarios && historicoComUsuarios.length > 0) {
|
|
historicoComUsuarios = await Promise.all(
|
|
historicoComUsuarios.map(async (hist) => {
|
|
const usuario = await ctx.db.get(hist.usuarioId);
|
|
return {
|
|
...hist,
|
|
usuarioNome: usuario?.nome || 'Usuário Desconhecido'
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
return {
|
|
...ferias,
|
|
funcionario: funcionario
|
|
? {
|
|
...funcionario,
|
|
fotoPerfilUrl
|
|
}
|
|
: null,
|
|
gestor,
|
|
time,
|
|
historicoAlteracoes: historicoComUsuarios
|
|
};
|
|
}
|
|
});
|
|
|
|
// Mutation: Criar solicitação de férias (cria um registro por período)
|
|
export const criarSolicitacao = mutation({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
anoReferencia: v.number(),
|
|
periodos: v.array(periodoValidator),
|
|
observacao: v.optional(v.string())
|
|
},
|
|
returns: v.array(v.id('ferias')),
|
|
handler: async (ctx, args) => {
|
|
if (args.periodos.length === 0) {
|
|
throw new Error('É necessário adicionar pelo menos 1 período');
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(args.funcionarioId);
|
|
if (!funcionario) throw new Error('Funcionário não encontrado');
|
|
|
|
// Buscar usuário que está criando (pode não ser o próprio funcionário)
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.first();
|
|
|
|
const historicoInicial = [
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: usuario?._id || funcionario.gestorId!,
|
|
acao: 'Solicitação criada'
|
|
}
|
|
];
|
|
|
|
// Criar um registro para cada período
|
|
const idsCriados: Array<Id<'ferias'>> = [];
|
|
for (const periodo of args.periodos) {
|
|
const feriasId = await ctx.db.insert('ferias', {
|
|
funcionarioId: args.funcionarioId,
|
|
anoReferencia: args.anoReferencia,
|
|
dataInicio: periodo.dataInicio,
|
|
dataFim: periodo.dataFim,
|
|
diasFerias: periodo.diasCorridos,
|
|
status: 'aguardando_aprovacao',
|
|
observacao: args.observacao,
|
|
diasAbono: 0,
|
|
historicoAlteracoes: historicoInicial
|
|
});
|
|
idsCriados.push(feriasId);
|
|
}
|
|
|
|
// Notificar gestor (usar o primeiro ID criado)
|
|
if (funcionario.gestorId && idsCriados.length > 0) {
|
|
await ctx.db.insert('notificacoesFerias', {
|
|
destinatarioId: funcionario.gestorId,
|
|
feriasId: idsCriados[0],
|
|
tipo: 'nova_solicitacao',
|
|
lida: false,
|
|
mensagem: `${funcionario.nome} solicitou férias`
|
|
});
|
|
}
|
|
|
|
return idsCriados;
|
|
}
|
|
});
|
|
|
|
// Mutation: Aprovar período de férias individual
|
|
export const aprovar = mutation({
|
|
args: {
|
|
feriasId: v.id('ferias'),
|
|
gestorId: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Buscar o registro específico
|
|
const registro = await ctx.db.get(args.feriasId);
|
|
|
|
if (!registro) {
|
|
throw new Error('Período de férias não encontrado');
|
|
}
|
|
|
|
// Verificar se está aguardando aprovação
|
|
if (registro.status !== 'aguardando_aprovacao') {
|
|
throw new Error('Este período já foi processado');
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(registro.funcionarioId);
|
|
|
|
// Buscar nome do gestor para o histórico
|
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
|
const nomeGestor = gestorUsuario?.nome || 'Gestor';
|
|
|
|
// Atualizar o registro
|
|
await ctx.db.patch(registro._id, {
|
|
status: 'aprovado',
|
|
gestorId: args.gestorId,
|
|
dataAprovacao: Date.now(),
|
|
historicoAlteracoes: [
|
|
...(registro.historicoAlteracoes || []),
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: args.gestorId,
|
|
acao: `Aprovado por ${nomeGestor}`
|
|
}
|
|
]
|
|
});
|
|
|
|
// Notificar funcionário
|
|
if (funcionario) {
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
|
|
if (usuario) {
|
|
// Criar notificação in-app para funcionário
|
|
await ctx.db.insert('notificacoesFerias', {
|
|
destinatarioId: usuario._id,
|
|
feriasId: registro._id,
|
|
tipo: 'aprovado',
|
|
lida: false,
|
|
mensagem: `Período de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi aprovado por ${nomeGestor}!`
|
|
});
|
|
|
|
// Enviar email ao funcionário usando template (agendado via scheduler)
|
|
if (gestorUsuario) {
|
|
// Obter URL do sistema
|
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
|
urlSistema = `http://${urlSistema}`;
|
|
}
|
|
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: usuario.email,
|
|
destinatarioId: usuario._id,
|
|
templateCodigo: 'ferias_aprovada',
|
|
variaveis: {
|
|
funcionarioNome: usuario.nome,
|
|
gestorNome: gestorUsuario.nome,
|
|
dataInicio: formatarDataBR(registro.dataInicio),
|
|
dataFim: formatarDataBR(registro.dataFim),
|
|
diasFerias: registro.diasFerias.toString(),
|
|
urlSistema
|
|
},
|
|
enviadoPor: args.gestorId
|
|
});
|
|
} catch (error) {
|
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
|
console.warn(
|
|
'Erro ao agendar envio de email com template ferias_aprovada, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: usuario.email,
|
|
destinatarioId: usuario._id,
|
|
assunto: 'Solicitação de Férias Aprovada',
|
|
corpo: `<p>Olá ${usuario.nome},</p>
|
|
<p>Sua solicitação de férias foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
|
<ul>
|
|
<li><strong>Período:</strong> ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)}</li>
|
|
<li><strong>Dias:</strong> ${registro.diasFerias} dias</li>
|
|
</ul>`,
|
|
enviadoPor: args.gestorId
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation: Reprovar período de férias individual
|
|
export const reprovar = mutation({
|
|
args: {
|
|
feriasId: v.id('ferias'),
|
|
gestorId: v.id('usuarios'),
|
|
motivoReprovacao: v.string()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Buscar o registro específico
|
|
const registro = await ctx.db.get(args.feriasId);
|
|
|
|
if (!registro) {
|
|
throw new Error('Período de férias não encontrado');
|
|
}
|
|
|
|
// Verificar se está aguardando aprovação
|
|
if (registro.status !== 'aguardando_aprovacao') {
|
|
throw new Error('Este período já foi processado');
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(registro.funcionarioId);
|
|
|
|
// Atualizar o registro
|
|
await ctx.db.patch(registro._id, {
|
|
status: 'reprovado',
|
|
gestorId: args.gestorId,
|
|
dataReprovacao: Date.now(),
|
|
motivoReprovacao: args.motivoReprovacao,
|
|
historicoAlteracoes: [
|
|
...(registro.historicoAlteracoes || []),
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: args.gestorId,
|
|
acao: `Reprovado: ${args.motivoReprovacao}`
|
|
}
|
|
]
|
|
});
|
|
|
|
// Notificar funcionário
|
|
if (funcionario) {
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
|
|
if (usuario) {
|
|
await ctx.db.insert('notificacoesFerias', {
|
|
destinatarioId: usuario._id,
|
|
feriasId: registro._id,
|
|
tipo: 'reprovado',
|
|
lida: false,
|
|
mensagem: `Período de férias de ${registro.diasFerias} dias foi reprovado: ${args.motivoReprovacao}`
|
|
});
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation: Ajustar data e aprovar período individual
|
|
export const ajustarEAprovar = mutation({
|
|
args: {
|
|
feriasId: v.id('ferias'),
|
|
gestorId: v.id('usuarios'),
|
|
novaDataInicio: v.string(),
|
|
novaDataFim: v.string()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Buscar o registro específico
|
|
const registroAntigo = await ctx.db.get(args.feriasId);
|
|
|
|
if (!registroAntigo) {
|
|
throw new Error('Período de férias não encontrado');
|
|
}
|
|
|
|
// Verificar se está aguardando aprovação
|
|
if (registroAntigo.status !== 'aguardando_aprovacao') {
|
|
throw new Error('Este período já foi processado');
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(registroAntigo.funcionarioId);
|
|
|
|
// Calcular novos dias
|
|
const novosDias = calcularDiasEntreDatas(args.novaDataInicio, args.novaDataFim);
|
|
|
|
// Atualizar o registro com novas datas
|
|
await ctx.db.patch(registroAntigo._id, {
|
|
dataInicio: args.novaDataInicio,
|
|
dataFim: args.novaDataFim,
|
|
diasFerias: novosDias,
|
|
status: 'data_ajustada_aprovada',
|
|
gestorId: args.gestorId,
|
|
dataAprovacao: Date.now(),
|
|
historicoAlteracoes: [
|
|
...(registroAntigo.historicoAlteracoes || []),
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: args.gestorId,
|
|
acao: `Data ajustada e aprovada: ${registroAntigo.dataInicio} - ${registroAntigo.dataFim} → ${args.novaDataInicio} - ${args.novaDataFim}`
|
|
}
|
|
]
|
|
});
|
|
|
|
// Buscar nome do gestor
|
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
|
const nomeGestor = gestorUsuario?.nome || 'Gestor';
|
|
|
|
// Notificar funcionário
|
|
if (funcionario) {
|
|
const usuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
|
|
if (usuario) {
|
|
// Criar notificação in-app para funcionário
|
|
await ctx.db.insert('notificacoesFerias', {
|
|
destinatarioId: usuario._id,
|
|
feriasId: registroAntigo._id,
|
|
tipo: 'data_ajustada',
|
|
lida: false,
|
|
mensagem: `Período de férias foi aprovado com ajuste de datas: ${formatarDataBR(args.novaDataInicio)} a ${formatarDataBR(args.novaDataFim)} (${novosDias} dias) por ${nomeGestor}`
|
|
});
|
|
|
|
// Enviar email ao funcionário usando template (agendado via scheduler)
|
|
if (gestorUsuario) {
|
|
// Obter URL do sistema
|
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
|
urlSistema = `http://${urlSistema}`;
|
|
}
|
|
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: usuario.email,
|
|
destinatarioId: usuario._id,
|
|
templateCodigo: 'ferias_aprovada',
|
|
variaveis: {
|
|
funcionarioNome: usuario.nome,
|
|
gestorNome: gestorUsuario.nome,
|
|
dataInicio: formatarDataBR(args.novaDataInicio),
|
|
dataFim: formatarDataBR(args.novaDataFim),
|
|
diasFerias: novosDias.toString(),
|
|
urlSistema
|
|
},
|
|
enviadoPor: args.gestorId
|
|
});
|
|
} catch (error) {
|
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
|
console.warn(
|
|
'Erro ao agendar envio de email com template ferias_aprovada, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: usuario.email,
|
|
destinatarioId: usuario._id,
|
|
assunto: 'Solicitação de Férias Aprovada (com Ajuste de Datas)',
|
|
corpo: `<p>Olá ${usuario.nome},</p>
|
|
<p>Sua solicitação de férias foi <strong>aprovada com ajuste de datas</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
|
<ul>
|
|
<li><strong>Período:</strong> ${formatarDataBR(args.novaDataInicio)} até ${formatarDataBR(args.novaDataFim)}</li>
|
|
<li><strong>Dias:</strong> ${novosDias} dias</li>
|
|
</ul>`,
|
|
enviadoPor: args.gestorId
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Query: Verificar status de férias automático
|
|
export const verificarStatusFerias = query({
|
|
args: { funcionarioId: v.id('funcionarios') },
|
|
returns: v.union(v.literal('ativo'), v.literal('em_ferias')),
|
|
handler: async (ctx, args) => {
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
|
|
const feriasAprovadas = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', args.funcionarioId).eq('status', 'aprovado')
|
|
)
|
|
.collect();
|
|
|
|
const feriasAjustadas = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', args.funcionarioId).eq('status', 'data_ajustada_aprovada')
|
|
)
|
|
.collect();
|
|
|
|
const feriasEmFerias = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', args.funcionarioId).eq('status', 'EmFérias')
|
|
)
|
|
.collect();
|
|
|
|
const todasFerias = [...feriasAprovadas, ...feriasAjustadas, ...feriasEmFerias];
|
|
|
|
for (const ferias of todasFerias) {
|
|
const inicio = new Date(ferias.dataInicio);
|
|
const fim = new Date(ferias.dataFim);
|
|
inicio.setHours(0, 0, 0, 0);
|
|
fim.setHours(23, 59, 59, 999);
|
|
|
|
if (hoje >= inicio && hoje <= fim) {
|
|
return 'em_ferias';
|
|
}
|
|
}
|
|
|
|
return 'ativo';
|
|
}
|
|
});
|
|
|
|
// Query: Obter notificações não lidas
|
|
export const obterNotificacoesNaoLidas = query({
|
|
args: { usuarioId: v.id('usuarios') },
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db
|
|
.query('notificacoesFerias')
|
|
.withIndex('by_destinatario_and_lida', (q) =>
|
|
q.eq('destinatarioId', args.usuarioId).eq('lida', false)
|
|
)
|
|
.collect();
|
|
}
|
|
});
|
|
|
|
// Mutation: Marcar notificação como lida
|
|
export const marcarComoLida = mutation({
|
|
args: { notificacaoId: v.id('notificacoesFerias') },
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.notificacaoId, { lida: true });
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation: Atualizar status de um período individual
|
|
export const atualizarStatus = mutation({
|
|
args: {
|
|
feriasId: v.id('ferias'),
|
|
novoStatus: v.union(
|
|
v.literal('aguardando_aprovacao'),
|
|
v.literal('aprovado'),
|
|
v.literal('reprovado'),
|
|
v.literal('data_ajustada_aprovada'),
|
|
v.literal('Cancelado_RH')
|
|
),
|
|
usuarioId: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
// Buscar o registro específico
|
|
const registro = await ctx.db.get(args.feriasId);
|
|
|
|
if (!registro) {
|
|
throw new Error('Período de férias não encontrado');
|
|
}
|
|
|
|
// Atualizar status e histórico
|
|
const acao = `Status alterado para ${args.novoStatus}`;
|
|
|
|
const updateData: {
|
|
status: typeof args.novoStatus;
|
|
historicoAlteracoes: Array<{
|
|
data: number;
|
|
usuarioId: Id<'usuarios'>;
|
|
acao: string;
|
|
}>;
|
|
gestorId?: undefined;
|
|
dataAprovacao?: undefined;
|
|
dataReprovacao?: undefined;
|
|
motivoReprovacao?: undefined;
|
|
} = {
|
|
status: args.novoStatus,
|
|
historicoAlteracoes: [
|
|
...(registro.historicoAlteracoes || []),
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: args.usuarioId,
|
|
acao
|
|
}
|
|
]
|
|
};
|
|
|
|
// Se voltar para aguardando_aprovacao, limpar campos relacionados
|
|
if (args.novoStatus === 'aguardando_aprovacao') {
|
|
await ctx.db.patch(registro._id, {
|
|
...updateData,
|
|
gestorId: undefined,
|
|
dataAprovacao: undefined,
|
|
dataReprovacao: undefined,
|
|
motivoReprovacao: undefined
|
|
});
|
|
} else {
|
|
await ctx.db.patch(registro._id, updateData);
|
|
}
|
|
|
|
// Recalcular imediatamente o status de férias/licença do funcionário
|
|
// para refletir o cancelamento (ou outra mudança) sem depender apenas do cron diário
|
|
try {
|
|
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
|
|
funcionarioId: registro.funcionarioId
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
'[ferias.atualizarStatus] Erro ao atualizar statusFerias do funcionário:',
|
|
error
|
|
);
|
|
}
|
|
|
|
// Se o status foi alterado para Cancelado_RH, notificar o funcionário
|
|
if (args.novoStatus === 'Cancelado_RH') {
|
|
const funcionario = await ctx.db.get(registro.funcionarioId);
|
|
|
|
if (funcionario) {
|
|
const funcionarioUsuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
|
.first();
|
|
|
|
if (funcionarioUsuario) {
|
|
// Buscar usuário do RH que está cancelando
|
|
const usuarioRH = await ctx.db.get(args.usuarioId);
|
|
const nomeRH = usuarioRH?.nome || 'Recursos Humanos';
|
|
|
|
// Criar notificação in-app para funcionário
|
|
await ctx.db.insert('notificacoesFerias', {
|
|
destinatarioId: funcionarioUsuario._id,
|
|
feriasId: registro._id,
|
|
tipo: 'cancelado',
|
|
lida: false,
|
|
mensagem: `Sua solicitação de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi cancelada pelo setor de Recursos Humanos.`
|
|
});
|
|
|
|
// Obter URL do sistema
|
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
|
urlSistema = `http://${urlSistema}`;
|
|
}
|
|
|
|
// Enviar email ao funcionário usando template (agendado via scheduler)
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: funcionarioUsuario.email,
|
|
destinatarioId: funcionarioUsuario._id,
|
|
templateCodigo: 'ferias_cancelada_rh',
|
|
variaveis: {
|
|
funcionarioNome: funcionarioUsuario.nome,
|
|
dataInicio: formatarDataBR(registro.dataInicio),
|
|
dataFim: formatarDataBR(registro.dataFim),
|
|
diasFerias: registro.diasFerias.toString(),
|
|
urlSistema
|
|
},
|
|
enviadoPor: args.usuarioId
|
|
});
|
|
} catch (error) {
|
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
|
console.warn(
|
|
'Erro ao agendar envio de email com template ferias_cancelada_rh, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: funcionarioUsuario.email,
|
|
destinatarioId: funcionarioUsuario._id,
|
|
assunto: 'Solicitação de Férias Cancelada',
|
|
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
|
<p>Sua solicitação de férias foi <strong>cancelada</strong> pelo setor de Recursos Humanos:</p>
|
|
<ul>
|
|
<li><strong>Período:</strong> ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)}</li>
|
|
<li><strong>Dias:</strong> ${registro.diasFerias} dias</li>
|
|
</ul>
|
|
<p>Para mais informações, entre em contato com o setor de Recursos Humanos.</p>`,
|
|
enviadoPor: args.usuarioId
|
|
});
|
|
}
|
|
|
|
// Criar ou obter conversa entre RH e funcionário
|
|
const conversasExistentes = await ctx.db
|
|
.query('conversas')
|
|
.filter((q) => q.eq(q.field('tipo'), 'individual'))
|
|
.collect();
|
|
|
|
let conversaId: Id<'conversas'> | null = null;
|
|
for (const conversa of conversasExistentes) {
|
|
if (
|
|
conversa.participantes.length === 2 &&
|
|
conversa.participantes.includes(args.usuarioId) &&
|
|
conversa.participantes.includes(funcionarioUsuario._id)
|
|
) {
|
|
conversaId = conversa._id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!conversaId) {
|
|
conversaId = await ctx.db.insert('conversas', {
|
|
tipo: 'individual',
|
|
participantes: [args.usuarioId, funcionarioUsuario._id],
|
|
criadoPor: args.usuarioId,
|
|
criadoEm: Date.now()
|
|
});
|
|
}
|
|
|
|
// Criar mensagem de chat (texto simples)
|
|
await ctx.db.insert('mensagens', {
|
|
conversaId,
|
|
remetenteId: args.usuarioId,
|
|
tipo: 'texto',
|
|
conteudo: `Sua solicitação de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi cancelada pelo setor de Recursos Humanos. Para mais informações, entre em contato conosco.`,
|
|
enviadaEm: Date.now()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Internal Mutation: Atualizar status de todos os funcionários
|
|
export const atualizarStatusTodosFuncionarios = internalMutation({
|
|
args: {},
|
|
returns: v.null(),
|
|
handler: async (ctx) => {
|
|
const funcionarios = await ctx.db.query('funcionarios').collect();
|
|
|
|
for (const func of funcionarios) {
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
|
|
// Buscar todos os registros de férias que podem estar em férias
|
|
// Buscar por status específico para criar mapas de referência
|
|
const feriasAprovadas = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', func._id).eq('status', 'aprovado')
|
|
)
|
|
.collect();
|
|
|
|
const feriasAjustadas = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', func._id).eq('status', 'data_ajustada_aprovada')
|
|
)
|
|
.collect();
|
|
|
|
const feriasEmFerias = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', func._id).eq('status', 'EmFérias')
|
|
)
|
|
.collect();
|
|
|
|
// Criar mapas para verificar status original
|
|
// Quando um registro está "EmFérias", precisamos saber qual era o status anterior
|
|
// Vamos usar o histórico ou verificar se o ID estava nas listas antes
|
|
const idsAprovados = new Set(feriasAprovadas.map((f) => f._id));
|
|
const idsAjustados = new Set(feriasAjustadas.map((f) => f._id));
|
|
|
|
// Para registros que estão "EmFérias", verificar o histórico para determinar status anterior
|
|
// Se não houver histórico claro, usar lógica: se foi aprovado recentemente, provavelmente era "aprovado"
|
|
// Por enquanto, vamos usar uma heurística: se o registro está "EmFérias" e não está nas listas,
|
|
// vamos verificar o histórico de alterações para encontrar o status anterior
|
|
const statusAnteriorPorId = new Map<Id<'ferias'>, 'aprovado' | 'data_ajustada_aprovada'>();
|
|
|
|
for (const ferias of feriasEmFerias) {
|
|
// Verificar histórico para encontrar status anterior
|
|
if (ferias.historicoAlteracoes && ferias.historicoAlteracoes.length > 0) {
|
|
// Procurar pela última alteração que mudou para "EmFérias" ou antes disso
|
|
const historico = ferias.historicoAlteracoes;
|
|
for (let i = historico.length - 1; i >= 0; i--) {
|
|
const entrada = historico[i];
|
|
if (entrada.acao.includes('Aprovado') || entrada.acao.includes('aprovado')) {
|
|
statusAnteriorPorId.set(ferias._id, 'aprovado');
|
|
break;
|
|
} else if (
|
|
entrada.acao.includes('Data ajustada') ||
|
|
entrada.acao.includes('ajustada')
|
|
) {
|
|
statusAnteriorPorId.set(ferias._id, 'data_ajustada_aprovada');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Se não encontrou no histórico, usar fallback: assumir "aprovado"
|
|
if (!statusAnteriorPorId.has(ferias._id)) {
|
|
statusAnteriorPorId.set(ferias._id, 'aprovado');
|
|
}
|
|
}
|
|
|
|
// Combinar todos os registros
|
|
const todasFerias = [...feriasAprovadas, ...feriasAjustadas, ...feriasEmFerias];
|
|
|
|
let emFerias = false;
|
|
for (const ferias of todasFerias) {
|
|
const inicio = new Date(ferias.dataInicio);
|
|
const fim = new Date(ferias.dataFim);
|
|
inicio.setHours(0, 0, 0, 0);
|
|
fim.setHours(23, 59, 59, 999);
|
|
|
|
if (hoje >= inicio && hoje <= fim) {
|
|
emFerias = true;
|
|
|
|
// Atualizar status para "EmFérias" se ainda não estiver
|
|
if (ferias.status !== 'EmFérias') {
|
|
await ctx.db.patch(ferias._id, {
|
|
status: 'EmFérias'
|
|
});
|
|
}
|
|
} else {
|
|
// Se saiu do período e está "EmFérias", voltar para o status anterior
|
|
if (ferias.status === 'EmFérias') {
|
|
// Determinar status anterior
|
|
let statusAnterior: 'aprovado' | 'data_ajustada_aprovada';
|
|
|
|
if (idsAprovados.has(ferias._id)) {
|
|
statusAnterior = 'aprovado';
|
|
} else if (idsAjustados.has(ferias._id)) {
|
|
statusAnterior = 'data_ajustada_aprovada';
|
|
} else {
|
|
// Usar histórico ou fallback
|
|
statusAnterior = statusAnteriorPorId.get(ferias._id) || 'aprovado';
|
|
}
|
|
|
|
await ctx.db.patch(ferias._id, {
|
|
status: statusAnterior
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determinar o status: férias tem prioridade sobre licença
|
|
let novoStatus: 'ativo' | 'em_ferias' | 'em_licenca';
|
|
if (emFerias) {
|
|
novoStatus = 'em_ferias';
|
|
} else {
|
|
// Se não está em férias, verificar se está em licença
|
|
const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje);
|
|
novoStatus = emLicenca ? 'em_licenca' : 'ativo';
|
|
}
|
|
|
|
if (func.statusFerias !== novoStatus) {
|
|
await ctx.db.patch(func._id, { statusFerias: novoStatus });
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Internal Mutation: Atualizar status de um funcionário específico
|
|
export const atualizarStatusFuncionario = internalMutation({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const func = await ctx.db.get(args.funcionarioId);
|
|
if (!func) return null;
|
|
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
|
|
// Buscar todos os registros de férias que podem estar em férias
|
|
const feriasAprovadas = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', func._id).eq('status', 'aprovado')
|
|
)
|
|
.collect();
|
|
|
|
const feriasAjustadas = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', func._id).eq('status', 'data_ajustada_aprovada')
|
|
)
|
|
.collect();
|
|
|
|
const feriasEmFerias = await ctx.db
|
|
.query('ferias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', func._id).eq('status', 'EmFérias')
|
|
)
|
|
.collect();
|
|
|
|
const idsAprovados = new Set(feriasAprovadas.map((f) => f._id));
|
|
const idsAjustados = new Set(feriasAjustadas.map((f) => f._id));
|
|
const statusAnteriorPorId = new Map<Id<'ferias'>, 'aprovado' | 'data_ajustada_aprovada'>();
|
|
|
|
for (const ferias of feriasEmFerias) {
|
|
if (ferias.historicoAlteracoes && ferias.historicoAlteracoes.length > 0) {
|
|
const historico = ferias.historicoAlteracoes;
|
|
for (let i = historico.length - 1; i >= 0; i--) {
|
|
const entrada = historico[i];
|
|
if (entrada.acao.includes('Aprovado') || entrada.acao.includes('aprovado')) {
|
|
statusAnteriorPorId.set(ferias._id, 'aprovado');
|
|
break;
|
|
} else if (entrada.acao.includes('Data ajustada') || entrada.acao.includes('ajustada')) {
|
|
statusAnteriorPorId.set(ferias._id, 'data_ajustada_aprovada');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!statusAnteriorPorId.has(ferias._id)) {
|
|
statusAnteriorPorId.set(ferias._id, 'aprovado');
|
|
}
|
|
}
|
|
|
|
const todasFerias = [...feriasAprovadas, ...feriasAjustadas, ...feriasEmFerias];
|
|
|
|
let emFerias = false;
|
|
for (const ferias of todasFerias) {
|
|
const inicio = new Date(ferias.dataInicio);
|
|
const fim = new Date(ferias.dataFim);
|
|
inicio.setHours(0, 0, 0, 0);
|
|
fim.setHours(23, 59, 59, 999);
|
|
|
|
if (hoje >= inicio && hoje <= fim) {
|
|
emFerias = true;
|
|
|
|
if (ferias.status !== 'EmFérias') {
|
|
await ctx.db.patch(ferias._id, {
|
|
status: 'EmFérias'
|
|
});
|
|
}
|
|
} else {
|
|
if (ferias.status === 'EmFérias') {
|
|
let statusAnterior: 'aprovado' | 'data_ajustada_aprovada';
|
|
|
|
if (idsAprovados.has(ferias._id)) {
|
|
statusAnterior = 'aprovado';
|
|
} else if (idsAjustados.has(ferias._id)) {
|
|
statusAnterior = 'data_ajustada_aprovada';
|
|
} else {
|
|
statusAnterior = statusAnteriorPorId.get(ferias._id) || 'aprovado';
|
|
}
|
|
|
|
await ctx.db.patch(ferias._id, {
|
|
status: statusAnterior
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determinar o status: férias tem prioridade sobre licença
|
|
let novoStatus: 'ativo' | 'em_ferias' | 'em_licenca';
|
|
if (emFerias) {
|
|
novoStatus = 'em_ferias';
|
|
console.log(`[atualizarStatusFuncionario] Funcionário ${func._id} está em férias`);
|
|
} else {
|
|
// Se não está em férias, verificar se está em licença
|
|
console.log(
|
|
`[atualizarStatusFuncionario] Verificando licença ativa para funcionário ${func._id}, data: ${hoje.toISOString()}`
|
|
);
|
|
const emLicenca = await verificarLicencaAtiva(ctx, func._id, hoje);
|
|
novoStatus = emLicenca ? 'em_licenca' : 'ativo';
|
|
console.log(
|
|
`[atualizarStatusFuncionario] Funcionário ${func._id}: emLicenca=${emLicenca}, statusAtual=${func.statusFerias}, novoStatus=${novoStatus}`
|
|
);
|
|
}
|
|
|
|
if (func.statusFerias !== novoStatus) {
|
|
console.log(
|
|
`[atualizarStatusFuncionario] ⚠️ ATUALIZANDO status de "${func.statusFerias}" para "${novoStatus}"`
|
|
);
|
|
await ctx.db.patch(func._id, { statusFerias: novoStatus });
|
|
console.log(`[atualizarStatusFuncionario] ✅ Status atualizado com sucesso!`);
|
|
} else {
|
|
console.log(`[atualizarStatusFuncionario] Status já está correto: ${novoStatus}`);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation pública para atualizar status do funcionário atual (útil para debug/teste)
|
|
export const atualizarMeuStatus = mutation({
|
|
args: {},
|
|
returns: v.null(),
|
|
handler: async (ctx) => {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario || !usuario.funcionarioId) {
|
|
throw new Error('Usuário não encontrado ou não possui funcionário associado');
|
|
}
|
|
|
|
await ctx.runMutation(internal.ferias.atualizarStatusFuncionario, {
|
|
funcionarioId: usuario.funcionarioId
|
|
});
|
|
|
|
return null;
|
|
}
|
|
});
|