1000 lines
30 KiB
TypeScript
1000 lines
30 KiB
TypeScript
import { v } from 'convex/values';
|
|
import { mutation, query } from './_generated/server';
|
|
import type { QueryCtx, MutationCtx } from './_generated/server';
|
|
import { api } from './_generated/api';
|
|
import { internal } from './_generated/api';
|
|
import { Id, Doc } from './_generated/dataModel';
|
|
import { parseLocalDate, formatarDataBR } from './utils/datas';
|
|
import { getCurrentUserFunction } from './auth';
|
|
|
|
// Query: Listar todas as solicitações (para RH)
|
|
export const listarTodas = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'listar'
|
|
});
|
|
|
|
const solicitacoes = await ctx.db.query('solicitacoesAusencias').collect();
|
|
|
|
const solicitacoesComDetalhes = await Promise.all(
|
|
solicitacoes.map(async (s) => {
|
|
const funcionario = await ctx.db.get(s.funcionarioId);
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', s.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
let time = null;
|
|
if (membroTime) {
|
|
time = await ctx.db.get(membroTime.timeId);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...s,
|
|
funcionario: funcionario
|
|
? {
|
|
...funcionario,
|
|
fotoPerfilUrl
|
|
}
|
|
: null,
|
|
time
|
|
};
|
|
})
|
|
);
|
|
|
|
return solicitacoesComDetalhes.sort((a, b) => b.criadoEm - a.criadoEm);
|
|
}
|
|
});
|
|
|
|
// Query: Listar solicitações do funcionário
|
|
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) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'listar'
|
|
});
|
|
|
|
const solicitacoes = await ctx.db
|
|
.query('solicitacoesAusencias')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.order('desc')
|
|
.collect();
|
|
|
|
// Enriquecer com dados do funcionário e time
|
|
const solicitacoesComDetalhes = await Promise.all(
|
|
solicitacoes.map(async (s) => {
|
|
const funcionario = await ctx.db.get(s.funcionarioId);
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', s.funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
let time = null;
|
|
if (membroTime) {
|
|
time = await ctx.db.get(membroTime.timeId);
|
|
}
|
|
|
|
return {
|
|
...s,
|
|
funcionario,
|
|
time
|
|
};
|
|
})
|
|
);
|
|
|
|
return solicitacoesComDetalhes;
|
|
}
|
|
});
|
|
|
|
// Query: Listar solicitações dos subordinados (para gestores)
|
|
export const listarSolicitacoesSubordinados = query({
|
|
args: { gestorId: v.id('usuarios') },
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'aprovar'
|
|
});
|
|
|
|
// 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 solicitacoes: Array<
|
|
Doc<'solicitacoesAusencias'> & {
|
|
funcionario: (Doc<'funcionarios'> & { fotoPerfilUrl: string | null }) | null;
|
|
time: Doc<'times'> | null;
|
|
}
|
|
> = [];
|
|
|
|
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 solicitações de cada membro
|
|
for (const membro of membros) {
|
|
const solic = await ctx.db
|
|
.query('solicitacoesAusencias')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', membro.funcionarioId))
|
|
.collect();
|
|
|
|
// Adicionar info do funcionário
|
|
for (const s of solic) {
|
|
const funcionario = await ctx.db.get(s.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);
|
|
}
|
|
}
|
|
|
|
solicitacoes.push({
|
|
...s,
|
|
funcionario: funcionario
|
|
? {
|
|
...funcionario,
|
|
fotoPerfilUrl
|
|
}
|
|
: null,
|
|
time
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return solicitacoes.sort((a, b) => b.criadoEm - a.criadoEm);
|
|
}
|
|
});
|
|
|
|
// Query: Obter detalhes completos de uma solicitação
|
|
export const obterDetalhes = query({
|
|
args: { solicitacaoId: v.id('solicitacoesAusencias') },
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'listar'
|
|
});
|
|
|
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
|
if (!solicitacao) return null;
|
|
|
|
const funcionario = await ctx.db.get(solicitacao.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 (solicitacao.gestorId) {
|
|
gestor = await ctx.db.get(solicitacao.gestorId);
|
|
}
|
|
|
|
// Buscar time do funcionário
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', solicitacao.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 = solicitacao.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 {
|
|
...solicitacao,
|
|
funcionario: funcionario
|
|
? {
|
|
...funcionario,
|
|
fotoPerfilUrl
|
|
}
|
|
: null,
|
|
gestor,
|
|
time,
|
|
historicoAlteracoes: historicoComUsuarios
|
|
};
|
|
}
|
|
});
|
|
|
|
// Query: Obter notificações não lidas
|
|
export const obterNotificacoesNaoLidas = query({
|
|
args: { usuarioId: v.id('usuarios') },
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'listar'
|
|
});
|
|
|
|
const notificacoes = await ctx.db
|
|
.query('notificacoesAusencias')
|
|
.withIndex('by_destinatario_and_lida', (q) =>
|
|
q.eq('destinatarioId', args.usuarioId).eq('lida', false)
|
|
)
|
|
.order('desc')
|
|
.collect();
|
|
|
|
return notificacoes;
|
|
}
|
|
});
|
|
|
|
// Query: Contar solicitações pendentes para gestor
|
|
export const contarPendentesGestor = query({
|
|
args: { gestorId: v.id('usuarios') },
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'aprovar'
|
|
});
|
|
|
|
// 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();
|
|
|
|
let totalPendentes = 0;
|
|
|
|
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();
|
|
|
|
// Contar solicitações pendentes de cada membro
|
|
for (const membro of membros) {
|
|
const pendentes = await ctx.db
|
|
.query('solicitacoesAusencias')
|
|
.withIndex('by_funcionario_and_status', (q) =>
|
|
q.eq('funcionarioId', membro.funcionarioId).eq('status', 'aguardando_aprovacao')
|
|
)
|
|
.collect();
|
|
totalPendentes += pendentes.length;
|
|
}
|
|
}
|
|
|
|
return totalPendentes;
|
|
}
|
|
});
|
|
|
|
// Helper: Recalcular banco de horas em um período
|
|
async function recalcularBancoHorasPeriodo(
|
|
ctx: MutationCtx,
|
|
funcionarioId: Id<'funcionarios'>,
|
|
dataInicio: string,
|
|
dataFim: string
|
|
): Promise<void> {
|
|
// Gerar todas as datas do período
|
|
const dataInicioObj = new Date(dataInicio);
|
|
const dataFimObj = new Date(dataFim);
|
|
const datas: string[] = [];
|
|
const dataAtual = new Date(dataInicioObj);
|
|
|
|
while (dataAtual <= dataFimObj) {
|
|
const ano = dataAtual.getFullYear();
|
|
const mes = String(dataAtual.getMonth() + 1).padStart(2, '0');
|
|
const dia = String(dataAtual.getDate()).padStart(2, '0');
|
|
datas.push(`${ano}-${mes}-${dia}`);
|
|
dataAtual.setDate(dataAtual.getDate() + 1);
|
|
}
|
|
|
|
// Recalcular para cada data usando a mutation interna (agendar para execução assíncrona)
|
|
for (let i = 0; i < datas.length; i++) {
|
|
await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, {
|
|
funcionarioId,
|
|
data: datas[i]!
|
|
});
|
|
}
|
|
}
|
|
|
|
// Helper: Verificar se há sobreposição de datas
|
|
function verificarSobreposicao(
|
|
inicio1: string,
|
|
fim1: string,
|
|
inicio2: string,
|
|
fim2: string
|
|
): boolean {
|
|
const d1Inicio = parseLocalDate(inicio1);
|
|
const d1Fim = parseLocalDate(fim1);
|
|
const d2Inicio = parseLocalDate(inicio2);
|
|
const d2Fim = parseLocalDate(fim2);
|
|
|
|
return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
|
|
}
|
|
|
|
// Helper: Encontrar gestor do funcionário
|
|
async function encontrarGestorDoFuncionario(
|
|
ctx: QueryCtx | MutationCtx,
|
|
funcionarioId: Id<'funcionarios'>
|
|
): Promise<Id<'usuarios'> | null> {
|
|
const membroTime = await ctx.db
|
|
.query('timesMembros')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
|
|
.filter((q) => q.eq(q.field('ativo'), true))
|
|
.first();
|
|
|
|
if (!membroTime) return null;
|
|
|
|
const time = await ctx.db.get(membroTime.timeId);
|
|
if (!time) return null;
|
|
|
|
return time.gestorId;
|
|
}
|
|
|
|
// Mutation: Criar solicitação de ausência
|
|
export const criarSolicitacao = mutation({
|
|
args: {
|
|
funcionarioId: v.id('funcionarios'),
|
|
dataInicio: v.string(),
|
|
dataFim: v.string(),
|
|
motivo: v.string()
|
|
},
|
|
returns: v.id('solicitacoesAusencias'),
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'criar'
|
|
});
|
|
|
|
// Validações
|
|
if (args.motivo.trim().length < 10) {
|
|
throw new Error('O motivo deve ter no mínimo 10 caracteres');
|
|
}
|
|
|
|
const dataInicio = parseLocalDate(args.dataInicio);
|
|
const dataFim = parseLocalDate(args.dataFim);
|
|
|
|
// Criar data de hoje em UTC para comparação
|
|
const hoje = new Date();
|
|
const hojeUTC = new Date(
|
|
Date.UTC(hoje.getUTCFullYear(), hoje.getUTCMonth(), hoje.getUTCDate(), 0, 0, 0, 0)
|
|
);
|
|
|
|
if (dataInicio < hojeUTC) {
|
|
throw new Error('A data de início não pode ser no passado');
|
|
}
|
|
|
|
if (dataFim < dataInicio) {
|
|
throw new Error('A data de fim deve ser maior ou igual à data de início');
|
|
}
|
|
|
|
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 usuarioCriador = await getCurrentUserFunction(ctx);
|
|
const usuarioFuncionario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.first();
|
|
|
|
// Usar o usuário autenticado se disponível, senão usar o usuário do funcionário ou gestor
|
|
const usuarioIdParaHistorico =
|
|
usuarioCriador?._id || usuarioFuncionario?._id || funcionario.gestorId;
|
|
|
|
// Verificar sobreposição com outras solicitações aprovadas ou pendentes
|
|
const solicitacoesExistentes = await ctx.db
|
|
.query('solicitacoesAusencias')
|
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.collect();
|
|
|
|
for (const solic of solicitacoesExistentes) {
|
|
if (solic.status === 'aprovado' || solic.status === 'aguardando_aprovacao') {
|
|
if (verificarSobreposicao(args.dataInicio, args.dataFim, solic.dataInicio, solic.dataFim)) {
|
|
throw new Error('Já existe uma solicitação aprovada ou pendente para este período');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Criar histórico inicial
|
|
const historicoInicial = usuarioIdParaHistorico
|
|
? [
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: usuarioIdParaHistorico,
|
|
acao: 'Solicitação criada'
|
|
}
|
|
]
|
|
: [];
|
|
|
|
// Criar solicitação
|
|
const solicitacaoId = await ctx.db.insert('solicitacoesAusencias', {
|
|
funcionarioId: args.funcionarioId,
|
|
dataInicio: args.dataInicio,
|
|
dataFim: args.dataFim,
|
|
motivo: args.motivo.trim(),
|
|
status: 'aguardando_aprovacao',
|
|
criadoEm: Date.now(),
|
|
historicoAlteracoes: historicoInicial
|
|
});
|
|
|
|
// Encontrar gestor do funcionário
|
|
const gestorId = await encontrarGestorDoFuncionario(ctx, args.funcionarioId);
|
|
|
|
if (gestorId) {
|
|
// Criar notificação in-app para gestor
|
|
await ctx.db.insert('notificacoesAusencias', {
|
|
destinatarioId: gestorId,
|
|
solicitacaoAusenciaId: solicitacaoId,
|
|
tipo: 'nova_solicitacao',
|
|
lida: false,
|
|
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
|
|
const gestorUsuario = await ctx.db.get(gestorId);
|
|
const funcionarioUsuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
|
|
.first();
|
|
|
|
if (gestorUsuario && funcionarioUsuario) {
|
|
// Obter URL do sistema
|
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
|
urlSistema = `http://${urlSistema}`;
|
|
}
|
|
|
|
// Enviar email ao gestor usando template (agendado via scheduler)
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: gestorUsuario.email,
|
|
destinatarioId: gestorId,
|
|
templateCodigo: 'ausencia_solicitada',
|
|
variaveis: {
|
|
gestorNome: gestorUsuario.nome,
|
|
funcionarioNome: funcionario.nome,
|
|
dataInicio: formatarDataBR(args.dataInicio),
|
|
dataFim: formatarDataBR(args.dataFim),
|
|
motivo: args.motivo,
|
|
urlSistema
|
|
},
|
|
enviadoPor: funcionarioUsuario._id
|
|
});
|
|
} 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 ausencia_solicitada, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: gestorUsuario.email,
|
|
destinatarioId: gestorId,
|
|
assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
|
|
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> ${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>`,
|
|
enviadoPor: funcionarioUsuario._id
|
|
});
|
|
}
|
|
|
|
// Criar ou obter conversa entre gestor 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(gestorId) &&
|
|
conversa.participantes.includes(funcionarioUsuario._id)
|
|
) {
|
|
conversaId = conversa._id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!conversaId) {
|
|
conversaId = await ctx.db.insert('conversas', {
|
|
tipo: 'individual',
|
|
participantes: [gestorId, funcionarioUsuario._id],
|
|
criadoPor: funcionarioUsuario._id,
|
|
criadoEm: Date.now()
|
|
});
|
|
}
|
|
|
|
// Criar mensagem de chat
|
|
await ctx.db.insert('mensagens', {
|
|
conversaId,
|
|
remetenteId: funcionarioUsuario._id,
|
|
tipo: 'texto',
|
|
conteudo: `Solicitei uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}. Motivo: ${args.motivo}`,
|
|
enviadaEm: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
return solicitacaoId;
|
|
}
|
|
});
|
|
|
|
// Mutation: Aprovar ausência
|
|
export const aprovar = mutation({
|
|
args: {
|
|
solicitacaoId: v.id('solicitacoesAusencias'),
|
|
gestorId: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'aprovar'
|
|
});
|
|
|
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
|
if (!solicitacao) {
|
|
throw new Error('Solicitação não encontrada');
|
|
}
|
|
|
|
if (solicitacao.status !== 'aguardando_aprovacao') {
|
|
throw new Error('Esta solicitação já foi processada');
|
|
}
|
|
|
|
// Verificar se o gestor tem permissão (é gestor do time do funcionário)
|
|
const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
|
|
ctx,
|
|
solicitacao.funcionarioId
|
|
);
|
|
|
|
if (gestorIdDoFuncionario !== args.gestorId) {
|
|
throw new Error('Você não tem permissão para aprovar esta solicitação');
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
|
|
if (!funcionario) {
|
|
throw new Error('Funcionário não encontrado');
|
|
}
|
|
|
|
// Buscar nome do gestor para o histórico
|
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
|
const nomeGestor = gestorUsuario?.nome || 'Gestor';
|
|
|
|
// Atualizar solicitação
|
|
await ctx.db.patch(args.solicitacaoId, {
|
|
status: 'aprovado',
|
|
gestorId: args.gestorId,
|
|
dataAprovacao: Date.now(),
|
|
historicoAlteracoes: [
|
|
...(solicitacao.historicoAlteracoes || []),
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: args.gestorId,
|
|
acao: `Aprovado por ${nomeGestor}`
|
|
}
|
|
]
|
|
});
|
|
|
|
// Buscar usuário do funcionário
|
|
const funcionarioUsuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', solicitacao.funcionarioId))
|
|
.first();
|
|
|
|
if (funcionarioUsuario) {
|
|
// Criar notificação in-app para funcionário
|
|
await ctx.db.insert('notificacoesAusencias', {
|
|
destinatarioId: funcionarioUsuario._id,
|
|
solicitacaoAusenciaId: args.solicitacaoId,
|
|
tipo: 'aprovado',
|
|
lida: false,
|
|
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);
|
|
|
|
if (gestorUsuario) {
|
|
// 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: 'ausencia_aprovada',
|
|
variaveis: {
|
|
funcionarioNome: funcionarioUsuario.nome,
|
|
gestorNome: gestorUsuario.nome,
|
|
dataInicio: formatarDataBR(solicitacao.dataInicio),
|
|
dataFim: formatarDataBR(solicitacao.dataFim),
|
|
motivo: solicitacao.motivo,
|
|
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 ausencia_aprovada, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: funcionarioUsuario.email,
|
|
destinatarioId: funcionarioUsuario._id,
|
|
assunto: 'Solicitação de Ausência Aprovada',
|
|
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> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
|
|
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
|
</ul>`,
|
|
enviadoPor: args.gestorId
|
|
});
|
|
}
|
|
|
|
// Criar ou obter conversa
|
|
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.gestorId) &&
|
|
conversa.participantes.includes(funcionarioUsuario._id)
|
|
) {
|
|
conversaId = conversa._id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!conversaId) {
|
|
conversaId = await ctx.db.insert('conversas', {
|
|
tipo: 'individual',
|
|
participantes: [args.gestorId, funcionarioUsuario._id],
|
|
criadoPor: args.gestorId,
|
|
criadoEm: Date.now()
|
|
});
|
|
}
|
|
|
|
// Criar mensagem de chat
|
|
await ctx.db.insert('mensagens', {
|
|
conversaId,
|
|
remetenteId: args.gestorId,
|
|
tipo: 'texto',
|
|
conteudo: `Aprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}.`,
|
|
enviadaEm: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
// Recalcular banco de horas para todas as datas do período da ausência aprovada
|
|
await recalcularBancoHorasPeriodo(
|
|
ctx,
|
|
solicitacao.funcionarioId,
|
|
solicitacao.dataInicio,
|
|
solicitacao.dataFim
|
|
);
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation: Reprovar ausência
|
|
export const reprovar = mutation({
|
|
args: {
|
|
solicitacaoId: v.id('solicitacoesAusencias'),
|
|
gestorId: v.id('usuarios'),
|
|
motivoReprovacao: v.string()
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'reprovar'
|
|
});
|
|
|
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
|
if (!solicitacao) {
|
|
throw new Error('Solicitação não encontrada');
|
|
}
|
|
|
|
if (solicitacao.status !== 'aguardando_aprovacao') {
|
|
throw new Error('Esta solicitação já foi processada');
|
|
}
|
|
|
|
// Verificar se o gestor tem permissão
|
|
const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
|
|
ctx,
|
|
solicitacao.funcionarioId
|
|
);
|
|
|
|
if (gestorIdDoFuncionario !== args.gestorId) {
|
|
throw new Error('Você não tem permissão para reprovar esta solicitação');
|
|
}
|
|
|
|
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
|
|
if (!funcionario) {
|
|
throw new Error('Funcionário não encontrado');
|
|
}
|
|
|
|
// Buscar nome do gestor para o histórico
|
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
|
const nomeGestor = gestorUsuario?.nome || 'Gestor';
|
|
|
|
// Atualizar solicitação
|
|
await ctx.db.patch(args.solicitacaoId, {
|
|
status: 'reprovado',
|
|
gestorId: args.gestorId,
|
|
dataReprovacao: Date.now(),
|
|
motivoReprovacao: args.motivoReprovacao.trim(),
|
|
historicoAlteracoes: [
|
|
...(solicitacao.historicoAlteracoes || []),
|
|
{
|
|
data: Date.now(),
|
|
usuarioId: args.gestorId,
|
|
acao: `Reprovado por ${nomeGestor}: ${args.motivoReprovacao.trim()}`
|
|
}
|
|
]
|
|
});
|
|
|
|
// Buscar usuário do funcionário
|
|
const funcionarioUsuario = await ctx.db
|
|
.query('usuarios')
|
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', solicitacao.funcionarioId))
|
|
.first();
|
|
|
|
if (funcionarioUsuario) {
|
|
// Criar notificação in-app para funcionário
|
|
await ctx.db.insert('notificacoesAusencias', {
|
|
destinatarioId: funcionarioUsuario._id,
|
|
solicitacaoAusenciaId: args.solicitacaoId,
|
|
tipo: 'reprovado',
|
|
lida: false,
|
|
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);
|
|
|
|
if (gestorUsuario) {
|
|
// 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: 'ausencia_reprovada',
|
|
variaveis: {
|
|
funcionarioNome: funcionarioUsuario.nome,
|
|
gestorNome: gestorUsuario.nome,
|
|
dataInicio: formatarDataBR(solicitacao.dataInicio),
|
|
dataFim: formatarDataBR(solicitacao.dataFim),
|
|
motivo: solicitacao.motivo,
|
|
motivoReprovacao: args.motivoReprovacao,
|
|
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 ausencia_reprovada, usando envio direto:',
|
|
error
|
|
);
|
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
|
destinatario: funcionarioUsuario.email,
|
|
destinatarioId: funcionarioUsuario._id,
|
|
assunto: 'Solicitação de Ausência Reprovada',
|
|
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> ${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>`,
|
|
enviadoPor: args.gestorId
|
|
});
|
|
}
|
|
|
|
// Criar ou obter conversa
|
|
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.gestorId) &&
|
|
conversa.participantes.includes(funcionarioUsuario._id)
|
|
) {
|
|
conversaId = conversa._id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!conversaId) {
|
|
conversaId = await ctx.db.insert('conversas', {
|
|
tipo: 'individual',
|
|
participantes: [args.gestorId, funcionarioUsuario._id],
|
|
criadoPor: args.gestorId,
|
|
criadoEm: Date.now()
|
|
});
|
|
}
|
|
|
|
// Criar mensagem de chat
|
|
await ctx.db.insert('mensagens', {
|
|
conversaId,
|
|
remetenteId: args.gestorId,
|
|
tipo: 'texto',
|
|
conteudo: `Reprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}. Motivo: ${args.motivoReprovacao}`,
|
|
enviadaEm: Date.now()
|
|
});
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation: Marcar notificação como lida
|
|
export const marcarComoLida = mutation({
|
|
args: {
|
|
notificacaoId: v.id('notificacoesAusencias')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'listar'
|
|
});
|
|
|
|
await ctx.db.patch(args.notificacaoId, {
|
|
lida: true
|
|
});
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Mutation: Excluir solicitação de ausência
|
|
export const excluirSolicitacao = mutation({
|
|
args: {
|
|
solicitacaoId: v.id('solicitacoesAusencias'),
|
|
usuarioId: v.id('usuarios')
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
|
recurso: 'ausencias',
|
|
acao: 'reprovar'
|
|
});
|
|
|
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
|
if (!solicitacao) {
|
|
throw new Error('Solicitação não encontrada');
|
|
}
|
|
|
|
// IMPORTANTE: Salvar o período exato da ausência ANTES de excluir
|
|
// para recalcular o banco de horas apenas para esse período específico
|
|
const funcionarioId = solicitacao.funcionarioId;
|
|
const dataInicio = solicitacao.dataInicio;
|
|
const dataFim = solicitacao.dataFim;
|
|
const statusOriginal = solicitacao.status;
|
|
const ausenciaId = args.solicitacaoId.toString(); // ID da ausência para remover ajustes
|
|
|
|
// Verificar se o usuário é o criador original da solicitação
|
|
const usuario = await ctx.db.get(args.usuarioId);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não encontrado');
|
|
}
|
|
|
|
const usuarioEhFuncionario = usuario.funcionarioId === solicitacao.funcionarioId;
|
|
const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
|
|
ctx,
|
|
solicitacao.funcionarioId
|
|
);
|
|
const usuarioEhGestor = gestorIdDoFuncionario === args.usuarioId;
|
|
|
|
if (!usuarioEhFuncionario && !usuarioEhGestor) {
|
|
throw new Error('Você não tem permissão para excluir esta solicitação');
|
|
}
|
|
|
|
// Permitir exclusão de ausências aprovadas (não apenas pendentes)
|
|
// Se estiver aprovada, o gestor pode excluir para corrigir erros
|
|
if (statusOriginal === 'aprovado' && !usuarioEhGestor) {
|
|
throw new Error('Apenas o gestor pode excluir ausências aprovadas');
|
|
}
|
|
|
|
// Excluir o registro do banco de dados
|
|
await ctx.db.delete(args.solicitacaoId);
|
|
|
|
// Remover ajustes automáticos relacionados à ausência excluída (apenas se estava aprovada)
|
|
if (statusOriginal === 'aprovado') {
|
|
try {
|
|
await ctx.runMutation(internal.pontos.removerAjustesAutomaticosInternal, {
|
|
funcionarioId,
|
|
motivoTipo: 'ausencia',
|
|
motivoId: ausenciaId,
|
|
dataInicio,
|
|
dataFim
|
|
});
|
|
} catch (error) {
|
|
console.error('[excluirSolicitacao] Erro ao remover ajustes automáticos:', error);
|
|
}
|
|
|
|
// Recalcular banco de horas APENAS para o período específico da ausência excluída
|
|
// Isso garante que os dias da ausência sejam removidos corretamente dos registros de ponto
|
|
await recalcularBancoHorasPeriodo(ctx, funcionarioId, dataInicio, dataFim);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
});
|