feat: enhance vacation management system with new employee association functionality, improved email notification handling, and comprehensive documentation; update dependencies and UI components for better user experience

This commit is contained in:
2025-10-30 09:27:10 -03:00
parent 21b41121db
commit fd445e8246
43 changed files with 6097 additions and 515 deletions

View File

@@ -17,6 +17,8 @@ import type * as betterAuth_adapter from "../betterAuth/adapter.js";
import type * as betterAuth_auth from "../betterAuth/auth.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as criarFuncionarioTeste from "../criarFuncionarioTeste.js";
import type * as criarUsuarioTeste from "../criarUsuarioTeste.js";
import type * as crons from "../crons.js";
import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js";
@@ -36,6 +38,7 @@ import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js";
import type * as monitoramento from "../monitoramento.js";
import type * as perfisCustomizados from "../perfisCustomizados.js";
import type * as roles from "../roles.js";
import type * as saldoFerias from "../saldoFerias.js";
import type * as seed from "../seed.js";
import type * as simbolos from "../simbolos.js";
import type * as solicitacoesAcesso from "../solicitacoesAcesso.js";
@@ -69,6 +72,8 @@ declare const fullApi: ApiFromModules<{
"betterAuth/auth": typeof betterAuth_auth;
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
criarFuncionarioTeste: typeof criarFuncionarioTeste;
criarUsuarioTeste: typeof criarUsuarioTeste;
crons: typeof crons;
cursos: typeof cursos;
dashboard: typeof dashboard;
@@ -88,6 +93,7 @@ declare const fullApi: ApiFromModules<{
monitoramento: typeof monitoramento;
perfisCustomizados: typeof perfisCustomizados;
roles: typeof roles;
saldoFerias: typeof saldoFerias;
seed: typeof seed;
simbolos: typeof simbolos;
solicitacoesAcesso: typeof solicitacoesAcesso;

View File

@@ -187,6 +187,7 @@ export const enviarMensagem = mutation({
arquivoTamanho: v.optional(v.number()),
arquivoTipo: v.optional(v.string()),
mencoes: v.optional(v.array(v.id("usuarios"))),
permitirNotificacaoParaSiMesmo: v.optional(v.boolean()), // ✅ NOVO: Permite criar notificação para si mesmo
},
handler: async (ctx, args) => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
@@ -219,10 +220,14 @@ export const enviarMensagem = mutation({
ultimaMensagemTimestamp: Date.now(),
});
// Criar notificações para outros participantes (com tratamento de erro)
// Criar notificações para participantes (com tratamento de erro)
try {
for (const participanteId of conversa.participantes) {
if (participanteId !== usuarioAtual._id) {
// ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa
const ehOMesmoUsuario = participanteId === usuarioAtual._id;
const deveCriarNotificacao = !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo;
if (deveCriarNotificacao) {
const tipoNotificacao = args.mencoes?.includes(participanteId)
? "mencao"
: "nova_mensagem";
@@ -593,15 +598,20 @@ export const listarConversas = query({
if (conversa.tipo === "individual") {
const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id);
if (outroUsuarioRaw) {
// Adicionar URL da foto de perfil
let fotoPerfilUrl = null;
if (outroUsuarioRaw.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(outroUsuarioRaw.fotoPerfil);
// 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot)
const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id);
if (usuarioAtualizado) {
// Adicionar URL da foto de perfil
let fotoPerfilUrl = null;
if (usuarioAtualizado.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtualizado.fotoPerfil);
}
outroUsuario = {
...usuarioAtualizado,
fotoPerfilUrl,
};
}
outroUsuario = {
...outroUsuarioRaw,
fotoPerfilUrl,
};
}
}

View File

@@ -0,0 +1,127 @@
import { v } from "convex/values";
import { mutation } from "./_generated/server";
/**
* Mutation de teste para criar um funcionário e associar ao usuário TI Master
* Isso permite testar o sistema de férias completo
*/
export const criarFuncionarioParaTIMaster = mutation({
args: {
usuarioEmail: v.string(), // Email do usuário TI Master
},
returns: v.union(
v.object({ sucesso: v.literal(true), funcionarioId: v.id("funcionarios") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Buscar usuário
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", args.usuarioEmail))
.first();
if (!usuario) {
return { sucesso: false as const, erro: "Usuário não encontrado" };
}
// Verificar se já tem funcionário associado
if (usuario.funcionarioId) {
return { sucesso: false as const, erro: "Usuário já tem funcionário associado" };
}
// Buscar um símbolo qualquer (pegamos o primeiro)
const simbolo = await ctx.db.query("simbolos").first();
if (!simbolo) {
return { sucesso: false as const, erro: "Nenhum símbolo encontrado no sistema" };
}
// Criar funcionário de teste
const funcionarioId = await ctx.db.insert("funcionarios", {
nome: usuario.nome,
cpf: "000.000.000-00", // CPF de teste
rg: "0000000",
endereco: "Endereço de Teste",
bairro: "Centro",
cidade: "Recife",
uf: "PE",
telefone: "(81) 99999-9999",
email: usuario.email,
matricula: usuario.matricula,
admissaoData: "2023-01-01", // Data de admissão: 1 ano atrás
simboloId: simbolo._id,
simboloTipo: simbolo.tipo,
statusFerias: "ativo",
// IMPORTANTE: Definir regime de trabalho
// Altere aqui para testar diferentes regimes:
// - "clt" = CLT (máx 3 períodos, mín 5 dias)
// - "estatutario_pe" = Servidor Público PE (máx 2 períodos, mín 10 dias)
regimeTrabalho: "clt",
// Dados opcionais
descricaoCargo: "Gestor de TI - Cargo de Teste",
nomePai: "Pai de Teste",
nomeMae: "Mãe de Teste",
naturalidade: "Recife",
naturalidadeUF: "PE",
sexo: "masculino",
estadoCivil: "solteiro",
nacionalidade: "Brasileira",
grauInstrucao: "superior_completo",
tipoSanguineo: "O+",
});
// Associar funcionário ao usuário
await ctx.db.patch(usuario._id, {
funcionarioId,
});
return { sucesso: true as const, funcionarioId };
},
});
/**
* Mutation para alterar o regime de trabalho de um funcionário
* Útil para testar diferentes regras (CLT vs Servidor PE)
*/
export const alterarRegimeTrabalho = mutation({
args: {
funcionarioId: v.id("funcionarios"),
novoRegime: v.union(
v.literal("clt"),
v.literal("estatutario_pe"),
v.literal("estatutario_federal"),
v.literal("estatutario_municipal")
),
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
await ctx.db.patch(args.funcionarioId, {
regimeTrabalho: args.novoRegime,
});
return { sucesso: true };
},
});
/**
* Mutation para alterar data de admissão
* Útil para testar diferentes períodos aquisitivos
*/
export const alterarDataAdmissao = mutation({
args: {
funcionarioId: v.id("funcionarios"),
novaData: v.string(), // Formato: "YYYY-MM-DD"
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
await ctx.db.patch(args.funcionarioId, {
admissaoData: args.novaData,
});
return { sucesso: true };
},
});

View File

@@ -0,0 +1,118 @@
import { v } from "convex/values";
import { mutation } from "./_generated/server";
import { hashPassword } from "./auth/utils";
/**
* Cria um usuário de teste com funcionário associado
* para testar o sistema de férias
*/
export const criarUsuarioParaTesteFerias = mutation({
args: {},
returns: v.object({
sucesso: v.boolean(),
login: v.string(),
senha: v.string(),
mensagem: v.string(),
}),
handler: async (ctx, args) => {
const loginTeste = "teste.ferias";
const senhaTeste = "Teste@2025";
const emailTeste = "teste.ferias@sgse.pe.gov.br";
const nomeTeste = "João Silva (Teste)";
// Verificar se já existe
const usuarioExistente = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", loginTeste))
.first();
if (usuarioExistente) {
return {
sucesso: true,
login: loginTeste,
senha: senhaTeste,
mensagem: "Usuário de teste já existe! Use as credenciais abaixo.",
};
}
// Buscar role padrão (usuário comum)
const roleUsuario = await ctx.db
.query("roles")
.filter((q) => q.eq(q.field("nome"), "usuario"))
.first();
if (!roleUsuario) {
return {
sucesso: false,
login: "",
senha: "",
mensagem: "Erro: Role 'usuario' não encontrada",
};
}
// Buscar um símbolo qualquer
const simbolo = await ctx.db.query("simbolos").first();
if (!simbolo) {
return {
sucesso: false,
login: "",
senha: "",
mensagem: "Erro: Nenhum símbolo encontrado. Crie um símbolo primeiro.",
};
}
// Criar funcionário
const funcionarioId = await ctx.db.insert("funcionarios", {
nome: nomeTeste,
cpf: "111.222.333-44",
rg: "1234567",
nascimento: "1990-05-15",
endereco: "Rua de Teste, 123",
bairro: "Centro",
cidade: "Recife",
uf: "PE",
cep: "50000-000",
telefone: "(81) 98765-4321",
email: emailTeste,
matricula: loginTeste,
admissaoData: "2023-01-15", // Admitido em jan/2023 (quase 2 anos)
simboloId: simbolo._id,
simboloTipo: simbolo.tipo,
statusFerias: "ativo",
regimeTrabalho: "clt", // CLT para testar
descricaoCargo: "Analista Administrativo",
nomePai: "José Silva",
nomeMae: "Maria Silva",
naturalidade: "Recife",
naturalidadeUF: "PE",
sexo: "masculino",
estadoCivil: "solteiro",
nacionalidade: "Brasileira",
grauInstrucao: "superior",
});
// Criar usuário
const senhaHash = await hashPassword(senhaTeste);
const usuarioId = await ctx.db.insert("usuarios", {
matricula: loginTeste,
senhaHash,
nome: nomeTeste,
email: emailTeste,
funcionarioId,
roleId: roleUsuario._id,
ativo: true,
primeiroAcesso: false, // Já consideramos que fez primeiro acesso
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
return {
sucesso: true,
login: loginTeste,
senha: senhaTeste,
mensagem: "Usuário de teste criado com sucesso!",
};
},
});

View File

@@ -25,5 +25,21 @@ crons.interval(
{}
);
// Criar períodos aquisitivos de férias automaticamente (diariamente)
crons.interval(
"criar-periodos-aquisitivos",
{ hours: 24 },
internal.saldoFerias.criarPeriodosAquisitivos,
{}
);
// Processar fila de emails pendentes a cada 2 minutos
crons.interval(
"processar-fila-emails",
{ minutes: 2 },
internal.email.processarFilaEmails,
{}
);
export default crons;

View File

@@ -98,6 +98,7 @@ export const listarFilaEmails = query({
)),
limite: v.optional(v.number()),
},
returns: v.array(v.any()),
handler: async (ctx, args) => {
let query = ctx.db.query("notificacoesEmail");
@@ -140,9 +141,7 @@ export const reenviarEmail = mutation({
});
/**
* Action para enviar email (será implementado com nodemailer)
*
* NOTA: Este é um placeholder. Implementação real requer nodemailer.
* Action para enviar email REAL usando nodemailer
*/
export const enviarEmailAction = action({
args: {
@@ -150,7 +149,9 @@ export const enviarEmailAction = action({
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
// TODO: Implementar com nodemailer quando instalado
"use node";
const nodemailer = require("nodemailer");
try {
// Buscar email da fila
@@ -171,7 +172,11 @@ export const enviarEmailAction = action({
});
if (!config) {
return { sucesso: false, erro: "Configuração de email não encontrada" };
return { sucesso: false, erro: "Configuração de email não encontrada ou inativa" };
}
if (!config.testado) {
return { sucesso: false, erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!" };
}
// Marcar como enviando
@@ -183,13 +188,33 @@ export const enviarEmailAction = action({
});
});
// TODO: Enviar email real com nodemailer aqui
console.log("⚠️ AVISO: Envio de email simulado (nodemailer não instalado)");
// Criar transporter do nodemailer
const transporter = nodemailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure, // true para 465, false para outros
auth: {
user: config.smtpUser,
pass: config.smtpPassword,
},
tls: {
// Não rejeitar certificados não autorizados (útil para testes)
rejectUnauthorized: false
}
});
// Enviar email REAL
const info = await transporter.sendMail({
from: `"${config.remetenteNome}" <${config.remetenteEmail}>`,
to: email.destinatario,
subject: email.assunto,
html: email.corpo,
});
console.log("✅ Email enviado com sucesso!");
console.log(" Para:", email.destinatario);
console.log(" Assunto:", email.assunto);
// Simular delay de envio
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(" Message ID:", info.messageId);
// Marcar como enviado
await ctx.runMutation(async (ctx) => {
@@ -201,6 +226,8 @@ export const enviarEmailAction = action({
return { sucesso: true };
} catch (error: any) {
console.error("❌ Erro ao enviar email:", error.message);
// Marcar como falha
await ctx.runMutation(async (ctx) => {
const email = await ctx.db.get(args.emailId);
@@ -221,6 +248,7 @@ export const enviarEmailAction = action({
*/
export const processarFilaEmails = internalMutation({
args: {},
returns: v.object({ processados: v.number() }),
handler: async (ctx) => {
// Buscar emails pendentes (max 10 por execução)
const emailsPendentes = await ctx.db
@@ -240,17 +268,17 @@ export const processarFilaEmails = internalMutation({
continue;
}
// Agendar envio (será feito por uma action separada)
// Por enquanto, apenas marca como enviado para não bloquear
await ctx.db.patch(email._id, {
status: "enviado",
enviadoEm: Date.now(),
// Agendar envio via action
// IMPORTANTE: Não podemos chamar action diretamente de mutation
// Por isso, usaremos o scheduler
await ctx.scheduler.runAfter(0, "email:enviarEmailAction" as any, {
emailId: email._id,
});
processados++;
}
console.log(`📧 Fila de emails processada: ${processados} emails`);
console.log(`📧 Fila de emails processada: ${processados} emails agendados para envio`);
return { processados };
},

View File

@@ -1,5 +1,6 @@
import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { Id } from "./_generated/dataModel";
// Validador para períodos
@@ -123,7 +124,7 @@ export const obterDetalhes = query({
},
});
// Mutation: Criar solicitação de férias
// Mutation: Criar solicitação de férias (com validação de saldo)
export const criarSolicitacao = mutation({
args: {
funcionarioId: v.id("funcionarios"),
@@ -137,13 +138,22 @@ export const criarSolicitacao = mutation({
throw new Error("É necessário adicionar pelo menos 1 período");
}
if (args.periodos.length > 3) {
throw new Error("Máximo de 3 períodos permitidos");
}
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) throw new Error("Funcionário não encontrado");
// Calcular total de dias
let totalDias = 0;
for (const p of args.periodos) {
totalDias += p.diasCorridos;
}
// Reservar dias no saldo (impede uso duplo)
await ctx.runMutation(internal.saldoFerias.reservarDias, {
funcionarioId: args.funcionarioId,
anoReferencia: args.anoReferencia,
totalDias,
});
// Buscar usuário que está criando (pode não ser o próprio funcionário)
const usuario = await ctx.db
.query("usuarios")
@@ -209,6 +219,11 @@ export const aprovar = mutation({
],
});
// Atualizar saldo (de pendente para usado)
await ctx.runMutation(internal.saldoFerias.atualizarSaldoAposAprovacao, {
solicitacaoId: args.solicitacaoId,
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
@@ -264,6 +279,11 @@ export const reprovar = mutation({
],
});
// Liberar dias reservados de volta ao saldo
await ctx.runMutation(internal.saldoFerias.liberarDias, {
solicitacaoId: args.solicitacaoId,
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
@@ -306,11 +326,24 @@ export const ajustarEAprovar = mutation({
throw new Error("É necessário adicionar pelo menos 1 período");
}
if (args.novosPeriodos.length > 3) {
throw new Error("Máximo de 3 períodos permitidos");
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
// Liberar dias antigos
await ctx.runMutation(internal.saldoFerias.liberarDias, {
solicitacaoId: args.solicitacaoId,
});
// Calcular novos dias e reservar
let totalNovosDias = 0;
for (const p of args.novosPeriodos) {
totalNovosDias += p.diasCorridos;
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
await ctx.runMutation(internal.saldoFerias.reservarDias, {
funcionarioId: solicitacao.funcionarioId,
anoReferencia: solicitacao.anoReferencia,
totalDias: totalNovosDias,
});
await ctx.db.patch(args.solicitacaoId, {
status: "data_ajustada_aprovada",
@@ -328,6 +361,11 @@ export const ajustarEAprovar = mutation({
],
});
// Atualizar saldo (marcar como usado)
await ctx.runMutation(internal.saldoFerias.atualizarSaldoAposAprovacao, {
solicitacaoId: args.solicitacaoId,
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db

View File

@@ -12,6 +12,7 @@ const aposentadoValidator = v.optional(v.union(v.literal("nao"), v.literal("funa
export const getAll = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
// Retornar apenas os campos necessários para listagem
@@ -39,6 +40,7 @@ export const getAll = query({
export const getById = query({
args: { id: v.id("funcionarios") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
@@ -301,6 +303,7 @@ export const remove = mutation({
// Query para obter ficha completa para impressão
export const getFichaCompleta = query({
args: { id: v.id("funcionarios") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const funcionario = await ctx.db.get(args.id);
if (!funcionario) {

View File

@@ -288,3 +288,4 @@ export const verificarNiveisIncorretos = query({
});

View File

@@ -208,3 +208,4 @@ export const removerAdminAntigo = internalMutation({
});

View File

@@ -0,0 +1,556 @@
import { v } from "convex/values";
import { query, mutation, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { Id } from "./_generated/dataModel";
/**
* SISTEMA DE CÁLCULO DE SALDO DE FÉRIAS
* Suporte a múltiplos regimes de trabalho
*
* ============================================
* REGRAS CLT (Consolidação das Leis do Trabalho):
* ============================================
* - 30 dias de férias por ano trabalhado
* - Período aquisitivo: 12 meses de trabalho
* - Período concessivo: 12 meses após aquisitivo
* - Pode dividir em até 3 períodos
* - Um período deve ter no mínimo 14 dias
* - Demais períodos: mínimo 5 dias cada
* - Abono pecuniário: vender 1/3 das férias (10 dias) - OPCIONAL
*
* ============================================
* REGRAS SERVIDOR PÚBLICO ESTADUAL DE PERNAMBUCO
* Lei nº 6.123/1968 - Estatuto dos Funcionários Públicos Civis do Estado de PE
* ============================================
* - 30 dias de férias por ano de exercício
* - Pode dividir em até 2 períodos (NÃO 3)
* - Nenhum período pode ser inferior a 10 dias (NÃO 5)
* - NÃO permite abono pecuniário (venda de férias)
* - Férias devem ser gozadas no ano subsequente
* - Servidor com mais de 10 anos: pode acumular até 2 períodos
* - Preferência: férias no período de 20/12 a 10/01 para docentes
* - Gestante: pode antecipar ou prorrogar férias
*/
type RegimeTrabalho = "clt" | "estatutario_pe" | "estatutario_federal" | "estatutario_municipal";
// Configurações por regime
const REGIMES_CONFIG = {
clt: {
nome: "CLT - Consolidação das Leis do Trabalho",
maxPeriodos: 3,
minDiasPeriodo: 5,
minDiasPeriodoPrincipal: 14,
abonoPermitido: true,
maxDiasAbono: 10,
},
estatutario_pe: {
nome: "Servidor Público Estadual de Pernambuco",
maxPeriodos: 2,
minDiasPeriodo: 10,
minDiasPeriodoPrincipal: null, // Não há essa regra
abonoPermitido: false,
maxDiasAbono: 0,
},
estatutario_federal: {
nome: "Servidor Público Federal",
maxPeriodos: 3,
minDiasPeriodo: 5,
minDiasPeriodoPrincipal: 14,
abonoPermitido: true,
maxDiasAbono: 10,
},
estatutario_municipal: {
nome: "Servidor Público Municipal",
maxPeriodos: 3,
minDiasPeriodo: 10,
minDiasPeriodoPrincipal: null,
abonoPermitido: false,
maxDiasAbono: 0,
},
};
// 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; // +1 para incluir ambos os dias
return diffDays;
}
// Helper: Calcular data de fim do período aquisitivo
function calcularDataFimPeriodo(dataAdmissao: string, anosPassados: number): string {
const dataInicio = new Date(dataAdmissao);
dataInicio.setFullYear(dataInicio.getFullYear() + anosPassados);
return dataInicio.toISOString().split('T')[0];
}
// Helper: Obter regime de trabalho do funcionário
async function obterRegimeTrabalho(ctx: any, funcionarioId: Id<"funcionarios">): Promise<RegimeTrabalho> {
const funcionario = await ctx.db.get(funcionarioId);
return funcionario?.regimeTrabalho || "clt"; // Default CLT
}
/**
* Query: Obter saldo de férias de um funcionário para um ano específico
*/
export const obterSaldo = query({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
},
returns: v.union(
v.object({
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
diasPendentes: v.number(),
diasDisponiveis: v.number(),
diasAbono: v.number(),
abonoPermitido: v.boolean(),
status: v.union(v.literal("ativo"), v.literal("vencido"), v.literal("concluido")),
dataInicio: v.string(),
dataFim: v.string(),
regimeTrabalho: v.string(),
}),
v.null()
),
handler: async (ctx, args) => {
// Buscar período aquisitivo
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", args.funcionarioId).eq("anoReferencia", args.anoReferencia)
)
.first();
if (!periodo) {
// Se não existe, criar automaticamente
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario || !funcionario.admissaoData) return null;
const regime = funcionario.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
// Calcular anos desde admissão
const dataAdmissao = new Date(funcionario.admissaoData);
const anosDesdeAdmissao = args.anoReferencia - dataAdmissao.getFullYear();
if (anosDesdeAdmissao < 1) return null; // Ainda não tem direito
const dataInicio = calcularDataFimPeriodo(funcionario.admissaoData, anosDesdeAdmissao - 1);
const dataFim = calcularDataFimPeriodo(funcionario.admissaoData, anosDesdeAdmissao);
// Criar período aquisitivo
await ctx.db.insert("periodosAquisitivos", {
funcionarioId: args.funcionarioId,
anoReferencia: args.anoReferencia,
dataInicio,
dataFim,
diasDireito: 30,
diasUsados: 0,
diasPendentes: 0,
diasDisponiveis: 30,
abonoPermitido: config.abonoPermitido,
diasAbono: 0,
status: "ativo",
});
return {
anoReferencia: args.anoReferencia,
diasDireito: 30,
diasUsados: 0,
diasPendentes: 0,
diasDisponiveis: 30,
diasAbono: 0,
abonoPermitido: config.abonoPermitido,
status: "ativo" as const,
dataInicio,
dataFim,
regimeTrabalho: config.nome,
};
}
const funcionario = await ctx.db.get(args.funcionarioId);
const regime = funcionario?.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
return {
anoReferencia: periodo.anoReferencia,
diasDireito: periodo.diasDireito,
diasUsados: periodo.diasUsados,
diasPendentes: periodo.diasPendentes,
diasDisponiveis: periodo.diasDisponiveis,
diasAbono: periodo.diasAbono,
abonoPermitido: config.abonoPermitido,
status: periodo.status,
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim,
regimeTrabalho: config.nome,
};
},
});
/**
* Query: Listar todos os saldos de um funcionário
*/
export const listarSaldos = query({
args: {
funcionarioId: v.id("funcionarios"),
},
returns: v.array(
v.object({
_id: v.id("periodosAquisitivos"),
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
diasPendentes: v.number(),
diasDisponiveis: v.number(),
diasAbono: v.number(),
abonoPermitido: v.boolean(),
status: v.union(v.literal("ativo"), v.literal("vencido"), v.literal("concluido")),
dataInicio: v.string(),
dataFim: v.string(),
})
),
handler: async (ctx, args) => {
const periodos = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.collect();
return periodos.map((p) => ({
_id: p._id,
anoReferencia: p.anoReferencia,
diasDireito: p.diasDireito,
diasUsados: p.diasUsados,
diasPendentes: p.diasPendentes,
diasDisponiveis: p.diasDisponiveis,
diasAbono: p.diasAbono,
abonoPermitido: p.abonoPermitido,
status: p.status,
dataInicio: p.dataInicio,
dataFim: p.dataFim,
}));
},
});
/**
* Query: Validar solicitação de férias (regras CLT ou Servidor Público PE)
*/
export const validarSolicitacao = query({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
periodos: v.array(
v.object({
dataInicio: v.string(),
dataFim: v.string(),
})
),
},
returns: v.object({
valido: v.boolean(),
erros: v.array(v.string()),
avisos: v.array(v.string()),
totalDias: v.number(),
regimeTrabalho: v.string(),
}),
handler: async (ctx, args) => {
const erros: string[] = [];
const avisos: string[] = [];
let totalDias = 0;
// Obter regime de trabalho
const regime = await obterRegimeTrabalho(ctx, args.funcionarioId);
const config = REGIMES_CONFIG[regime];
// Validação 1: Número de períodos
if (args.periodos.length === 0) {
erros.push("É necessário adicionar pelo menos 1 período de férias");
}
if (args.periodos.length > config.maxPeriodos) {
erros.push(
`Máximo de ${config.maxPeriodos} períodos permitidos para ${config.nome}`
);
}
// Calcular dias de cada período e validar
const diasPorPeriodo: number[] = [];
for (const periodo of args.periodos) {
const dias = calcularDiasEntreDatas(periodo.dataInicio, periodo.dataFim);
diasPorPeriodo.push(dias);
totalDias += dias;
// Validação 2: Mínimo de dias por período
if (dias < config.minDiasPeriodo) {
erros.push(
`Período de ${dias} dias é inválido. Mínimo: ${config.minDiasPeriodo} dias corridos (${config.nome})`
);
}
}
// Validação 3: CLT requer um período com 14+ dias se dividir
if (regime === "clt" && args.periodos.length > 1 && config.minDiasPeriodoPrincipal) {
const temPeriodo14Dias = diasPorPeriodo.some((d) => d >= config.minDiasPeriodoPrincipal);
if (!temPeriodo14Dias) {
erros.push(
`Ao dividir férias em CLT, um período deve ter no mínimo ${config.minDiasPeriodoPrincipal} dias corridos`
);
}
}
// Validação 4: Verificar saldo disponível
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", args.funcionarioId).eq("anoReferencia", args.anoReferencia)
)
.first();
if (!periodo) {
erros.push(`Você ainda não tem direito a férias referentes ao ano ${args.anoReferencia}`);
} else {
if (totalDias > periodo.diasDisponiveis) {
erros.push(
`Total solicitado (${totalDias} dias) excede saldo disponível (${periodo.diasDisponiveis} dias)`
);
}
// Aviso: Saldo baixo
if (periodo.diasDisponiveis < 15 && periodo.diasDisponiveis > totalDias) {
avisos.push(
`Após essa solicitação, restará ${periodo.diasDisponiveis - totalDias} dias de ${args.anoReferencia}`
);
}
// Aviso: Férias vencendo
const hoje = new Date();
const dataFim = new Date(periodo.dataFim);
const diasAteVencer = Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diasAteVencer < 90 && diasAteVencer > 0) {
avisos.push(
`⚠️ Atenção: Seu período aquisitivo ${periodo.anoReferencia} vence em ${diasAteVencer} dias!`
);
}
if (diasAteVencer < 0) {
avisos.push(
`⚠️ URGENTE: Seu período aquisitivo ${periodo.anoReferencia} está VENCIDO há ${Math.abs(diasAteVencer)} dias!`
);
}
}
// Validação 5: Verificar conflitos de datas (sobreposição)
for (let i = 0; i < args.periodos.length; i++) {
for (let j = i + 1; j < args.periodos.length; j++) {
const inicio1 = new Date(args.periodos[i].dataInicio);
const fim1 = new Date(args.periodos[i].dataFim);
const inicio2 = new Date(args.periodos[j].dataInicio);
const fim2 = new Date(args.periodos[j].dataFim);
if (
(inicio1 <= fim2 && fim1 >= inicio2) ||
(inicio2 <= fim1 && fim2 >= inicio1)
) {
erros.push("Os períodos não podem se sobrepor");
}
}
}
// Validação 6: Datas no futuro (aviso)
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
for (const periodo of args.periodos) {
const inicio = new Date(periodo.dataInicio);
if (inicio < hoje) {
avisos.push("⚠️ Período(s) com data de início no passado ou hoje");
break;
}
}
// Validação 7: Servidor PE - aviso sobre período preferencial para docentes
if (regime === "estatutario_pe") {
for (const periodo of args.periodos) {
const mes = new Date(periodo.dataInicio).getMonth() + 1;
if (mes === 12 || mes === 1) {
avisos.push("📅 Período preferencial para docentes (20/12 a 10/01)");
break;
}
}
}
return {
valido: erros.length === 0,
erros,
avisos,
totalDias,
regimeTrabalho: config.nome,
};
},
});
/**
* Internal Mutation: Atualizar saldo após aprovação de férias
*/
export const atualizarSaldoAposAprovacao = internalMutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
// Buscar período aquisitivo
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId).eq("anoReferencia", solicitacao.anoReferencia)
)
.first();
if (!periodo) return null;
// Calcular total de dias
let totalDias = 0;
for (const p of solicitacao.periodos) {
totalDias += p.diasCorridos;
}
// Atualizar saldo
await ctx.db.patch(periodo._id, {
diasPendentes: periodo.diasPendentes - totalDias,
diasUsados: periodo.diasUsados + totalDias,
diasDisponiveis: periodo.diasDireito - (periodo.diasUsados + totalDias) - periodo.diasAbono,
status: periodo.diasDireito - (periodo.diasUsados + totalDias) <= 0 ? "concluido" : periodo.status,
});
return null;
},
});
/**
* Internal Mutation: Reservar dias (ao criar solicitação)
*/
export const reservarDias = internalMutation({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
totalDias: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", args.funcionarioId).eq("anoReferencia", args.anoReferencia)
)
.first();
if (!periodo) return null;
await ctx.db.patch(periodo._id, {
diasPendentes: periodo.diasPendentes + args.totalDias,
diasDisponiveis: periodo.diasDisponiveis - args.totalDias,
});
return null;
},
});
/**
* Internal Mutation: Liberar dias (ao reprovar solicitação)
*/
export const liberarDias = internalMutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId).eq("anoReferencia", solicitacao.anoReferencia)
)
.first();
if (!periodo) return null;
let totalDias = 0;
for (const p of solicitacao.periodos) {
totalDias += p.diasCorridos;
}
await ctx.db.patch(periodo._id, {
diasPendentes: periodo.diasPendentes - totalDias,
diasDisponiveis: periodo.diasDisponiveis + totalDias,
});
return null;
},
});
/**
* Internal Mutation: Criar períodos aquisitivos para todos os funcionários
*/
export const criarPeriodosAquisitivos = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
const anoAtual = new Date().getFullYear();
for (const func of funcionarios) {
if (!func.admissaoData) continue;
const regime = func.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
const dataAdmissao = new Date(func.admissaoData);
const anosDesdeAdmissao = anoAtual - dataAdmissao.getFullYear();
// Criar períodos para os últimos 2 anos (atual e anterior)
for (let i = 0; i < 2; i++) {
const ano = anoAtual - i;
const anosPeriodo = ano - dataAdmissao.getFullYear();
if (anosPeriodo < 1) continue;
// Verificar se já existe
const periodoExistente = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", func._id).eq("anoReferencia", ano)
)
.first();
if (periodoExistente) continue;
const dataInicio = calcularDataFimPeriodo(func.admissaoData, anosPeriodo - 1);
const dataFim = calcularDataFimPeriodo(func.admissaoData, anosPeriodo);
await ctx.db.insert("periodosAquisitivos", {
funcionarioId: func._id,
anoReferencia: ano,
dataInicio,
dataFim,
diasDireito: 30,
diasUsados: 0,
diasPendentes: 0,
diasDisponiveis: 30,
abonoPermitido: config.abonoPermitido,
diasAbono: 0,
status: "ativo",
});
}
}
return null;
},
});

View File

@@ -36,6 +36,14 @@ export default defineSchema({
v.literal("ativo"),
v.literal("em_ferias")
)),
// Regime de trabalho (para cálculo correto de férias)
regimeTrabalho: v.optional(v.union(
v.literal("clt"), // CLT - Consolidação das Leis do Trabalho
v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco
v.literal("estatutario_federal"), // Servidor Público Federal
v.literal("estatutario_municipal") // Servidor Público Municipal
)),
// Dados Pessoais Adicionais (opcionais)
nomePai: v.optional(v.string()),
@@ -207,6 +215,28 @@ export default defineSchema({
.index("by_destinatario", ["destinatarioId"])
.index("by_destinatario_and_lida", ["destinatarioId", "lida"]),
// Períodos aquisitivos e saldos de férias
periodosAquisitivos: defineTable({
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(), // Ano do período aquisitivo (ex: 2024)
dataInicio: v.string(), // Data de início do período aquisitivo
dataFim: v.string(), // Data de fim do período aquisitivo
diasDireito: v.number(), // Dias de férias que tem direito (30 ou proporcional)
diasUsados: v.number(), // Dias já usados
diasPendentes: v.number(), // Dias em solicitações aguardando aprovação
diasDisponiveis: v.number(), // Dias disponíveis = direito - usados - pendentes
abonoPermitido: v.boolean(), // Se pode vender 1/3 das férias
diasAbono: v.number(), // Dias vendidos como abono pecuniário
status: v.union(
v.literal("ativo"), // Período vigente
v.literal("vencido"), // Período vencido (não tirou férias)
v.literal("concluido") // Período totalmente utilizado
),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_funcionario_and_ano", ["funcionarioId", "anoReferencia"])
.index("by_funcionario_and_status", ["funcionarioId", "status"]),
times: defineTable({
nome: v.string(),
descricao: v.optional(v.string()),
@@ -304,7 +334,8 @@ export default defineSchema({
.index("by_role", ["roleId"])
.index("by_ativo", ["ativo"])
.index("by_status_presenca", ["statusPresenca"])
.index("by_bloqueado", ["bloqueado"]),
.index("by_bloqueado", ["bloqueado"])
.index("by_funcionarioId", ["funcionarioId"]),
roles: defineTable({
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"

View File

@@ -4,6 +4,60 @@ import { hashPassword, generateToken } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
import { Id } from "./_generated/dataModel";
/**
* Associar funcionário a um usuário
*/
export const associarFuncionario = mutation({
args: {
usuarioId: v.id("usuarios"),
funcionarioId: v.id("funcionarios"),
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
// Verificar se o funcionário existe
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error("Funcionário não encontrado");
}
// Verificar se o funcionário já está associado a outro usuário
const usuarioExistente = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", args.funcionarioId))
.first();
if (usuarioExistente && usuarioExistente._id !== args.usuarioId) {
throw new Error(
`Este funcionário já está associado ao usuário: ${usuarioExistente.nome} (${usuarioExistente.matricula})`
);
}
// Associar funcionário ao usuário
await ctx.db.patch(args.usuarioId, {
funcionarioId: args.funcionarioId,
});
return { sucesso: true };
},
});
/**
* Desassociar funcionário de um usuário
*/
export const desassociarFuncionario = mutation({
args: {
usuarioId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
await ctx.db.patch(args.usuarioId, {
funcionarioId: undefined,
});
return { sucesso: true };
},
});
/**
* Criar novo usuário (apenas TI)
*/
@@ -405,6 +459,30 @@ export const atualizarPerfil = mutation({
*/
export const obterPerfil = query({
args: {},
returns: v.union(
v.object({
_id: v.id("usuarios"),
nome: v.string(),
email: v.string(),
matricula: v.string(),
funcionarioId: v.optional(v.id("funcionarios")),
avatar: v.optional(v.string()),
fotoPerfil: v.optional(v.id("_storage")),
fotoPerfilUrl: v.union(v.string(), v.null()),
setor: v.optional(v.string()),
statusMensagem: v.optional(v.string()),
statusPresenca: v.optional(v.union(
v.literal("online"),
v.literal("offline"),
v.literal("ausente"),
v.literal("externo"),
v.literal("em_reuniao")
)),
notificacoesAtivadas: v.boolean(),
somNotificacao: v.boolean(),
}),
v.null()
),
handler: async (ctx) => {
console.log("=== DEBUG obterPerfil ===");
@@ -464,6 +542,7 @@ export const obterPerfil = query({
nome: usuarioAtual.nome,
email: usuarioAtual.email,
matricula: usuarioAtual.matricula,
funcionarioId: usuarioAtual.funcionarioId,
avatar: usuarioAtual.avatar,
fotoPerfil: usuarioAtual.fotoPerfil,
fotoPerfilUrl,

View File

@@ -99,3 +99,4 @@ export const removerDuplicatas = internalMutation({
});

View File

@@ -9,13 +9,21 @@
"license": "ISC",
"description": "",
"devDependencies": {
"@types/cookie": "^1.0.0",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.3.0",
"@types/nodemailer": "^7.0.3",
"@types/pako": "^2.0.4",
"@types/raf": "^3.4.3",
"@types/trusted-types": "^2.0.7",
"typescript": "^5.9.2"
},
"dependencies": {
"@convex-dev/better-auth": "^0.9.6",
"@dicebear/avataaars": "^9.2.4",
"better-auth": "1.3.27",
"convex": "^1.28.0"
"convex": "^1.28.0",
"nodemailer": "^7.0.10"
}
}