Files
sgse-app/packages/backend/convex/ausencias.ts

751 lines
23 KiB
TypeScript

import { v } from 'convex/values';
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';
// Query: Listar todas as solicitações (para RH)
export const listarTodas = query({
args: {},
handler: async (ctx) => {
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);
}
return {
...s,
funcionario,
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) => {
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) => {
// 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) => {
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);
}
return {
...solicitacao,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
gestor,
time
};
}
});
// Query: Obter notificações não lidas
export const obterNotificacoesNaoLidas = query({
args: { usuarioId: v.id('usuarios') },
handler: async (ctx, args) => {
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) => {
// 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: Verificar se há sobreposição de datas
function verificarSobreposicao(
inicio1: string,
fim1: string,
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);
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) => {
// Validações
if (args.motivo.trim().length < 10) {
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 hoje = new Date();
hoje.setHours(0, 0, 0, 0);
if (dataInicio < hoje) {
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');
}
// 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 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()
});
// 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 ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}`
});
// 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: new Date(args.dataInicio).toLocaleDateString('pt-BR'),
dataFim: new Date(args.dataFim).toLocaleDateString('pt-BR'),
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> ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}</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 ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}. 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) => {
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');
}
// Atualizar solicitação
await ctx.db.patch(args.solicitacaoId, {
status: 'aprovado',
gestorId: args.gestorId,
dataAprovacao: Date.now()
});
// 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 ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} 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: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
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> ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</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 ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}.`,
enviadaEm: Date.now()
});
}
}
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) => {
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');
}
// Atualizar solicitação
await ctx.db.patch(args.solicitacaoId, {
status: 'reprovado',
gestorId: args.gestorId,
dataReprovacao: Date.now(),
motivoReprovacao: 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 ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} 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: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
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> ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</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 ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}. 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.db.patch(args.notificacaoId, {
lida: true
});
return null;
}
});