Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

View File

@@ -1,7 +1,11 @@
import { v } from 'convex/values';
import { mutation, query, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import { internalMutation, mutation, query } from './_generated/server';
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({
@@ -46,7 +50,7 @@ function agruparPorSolicitacao(registros: Array<Doc<'ferias'>>): Array<{
grupos.get(chave)!.push(registro);
}
return Array.from(grupos.entries()).map(([_, periodos]) => {
return Array.from(grupos.entries()).map(([, periodos]) => {
// Ordenar por data de criação para manter ordem
periodos.sort((a, b) => a._creationTime - b._creationTime);
@@ -137,7 +141,10 @@ export const listarTodas = query({
// Query: Listar solicitações do funcionário - períodos individuais
export const listarMinhasSolicitacoes = query({
args: { funcionarioId: v.id('funcionarios') },
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')
@@ -205,6 +212,18 @@ export const listarSolicitacoesSubordinados = query({
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')
@@ -219,7 +238,12 @@ export const listarSolicitacoesSubordinados = query({
return {
...ferias,
funcionario,
funcionario: funcionario
? {
...funcionario,
fotoPerfilUrl
}
: null,
time
};
})
@@ -240,6 +264,19 @@ export const obterDetalhes = query({
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);
@@ -257,11 +294,31 @@ export const obterDetalhes = query({
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
? {
...funcionario,
fotoPerfilUrl
}
: null,
gestor,
time
time,
historicoAlteracoes: historicoComUsuarios
};
}
});
@@ -351,6 +408,10 @@ export const aprovar = mutation({
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',
@@ -361,7 +422,7 @@ export const aprovar = mutation({
{
data: Date.now(),
usuarioId: args.gestorId,
acao: 'Aprovado'
acao: `Aprovado por ${nomeGestor}`
}
]
});
@@ -374,13 +435,58 @@ export const aprovar = mutation({
.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 ${registro.diasFerias} dias foi aprovado!`
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.SITE_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
});
}
}
}
}
@@ -494,6 +600,10 @@ export const ajustarEAprovar = mutation({
]
});
// 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
@@ -502,13 +612,58 @@ export const ajustarEAprovar = mutation({
.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: ${args.novaDataInicio} a ${args.novaDataFim}`
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.SITE_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
});
}
}
}
}
@@ -646,6 +801,124 @@ export const atualizarStatus = mutation({
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.SITE_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;
}
});
@@ -762,7 +1035,15 @@ export const atualizarStatusTodosFuncionarios = internalMutation({
}
}
const novoStatus = emFerias ? 'em_ferias' : 'ativo';
// 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 });
@@ -772,3 +1053,146 @@ export const atualizarStatusTodosFuncionarios = internalMutation({
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;
}
});