Merge remote-tracking branch 'origin/master' into feat-ajuste-acesso

This commit is contained in:
2025-10-30 14:01:08 -03:00
76 changed files with 15420 additions and 1212 deletions

View File

@@ -14,10 +14,14 @@ import type * as betterAuth__generated_api from "../betterAuth/_generated/api.js
import type * as betterAuth__generated_server from "../betterAuth/_generated/server.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";
import type * as documentos from "../documentos.js";
import type * as email from "../email.js";
import type * as ferias from "../ferias.js";
import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
@@ -25,15 +29,19 @@ import type * as limparPerfisAntigos from "../limparPerfisAntigos.js";
import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js";
import type * as menuPermissoes from "../menuPermissoes.js";
import type * as migrarParaTimes from "../migrarParaTimes.js";
import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js";
import type * as monitoramento from "../monitoramento.js";
import type * as perfisCustomizados from "../perfisCustomizados.js";
import type * as permissoesAcoes from "../permissoesAcoes.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";
import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
@@ -59,10 +67,14 @@ declare const fullApi: ApiFromModules<{
"betterAuth/_generated/server": typeof betterAuth__generated_server;
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
criarFuncionarioTeste: typeof criarFuncionarioTeste;
criarUsuarioTeste: typeof criarUsuarioTeste;
crons: typeof crons;
cursos: typeof cursos;
dashboard: typeof dashboard;
documentos: typeof documentos;
email: typeof email;
ferias: typeof ferias;
funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck;
http: typeof http;
@@ -70,15 +82,19 @@ declare const fullApi: ApiFromModules<{
logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin;
menuPermissoes: typeof menuPermissoes;
migrarParaTimes: typeof migrarParaTimes;
migrarUsuariosAdmin: typeof migrarUsuariosAdmin;
monitoramento: typeof monitoramento;
perfisCustomizados: typeof perfisCustomizados;
permissoesAcoes: typeof permissoesAcoes;
roles: typeof roles;
saldoFerias: typeof saldoFerias;
seed: typeof seed;
simbolos: typeof simbolos;
solicitacoesAcesso: typeof solicitacoesAcesso;
templatesMensagens: typeof templatesMensagens;
times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
verificarMatriculas: typeof verificarMatriculas;

View File

@@ -48,15 +48,8 @@ export const criarConversa = mutation({
avatar: v.optional(v.string()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
// Validar participantes
if (!args.participantes.includes(usuarioAtual._id)) {
@@ -194,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);
@@ -226,28 +220,38 @@ export const enviarMensagem = mutation({
ultimaMensagemTimestamp: Date.now(),
});
// Criar notificações para outros participantes
for (const participanteId of conversa.participantes) {
if (participanteId !== usuarioAtual._id) {
const tipoNotificacao = args.mencoes?.includes(participanteId)
? "mencao"
: "nova_mensagem";
// Criar notificações para participantes (com tratamento de erro)
try {
for (const participanteId of conversa.participantes) {
// ✅ 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";
await ctx.db.insert("notificacoes", {
usuarioId: participanteId,
tipo: tipoNotificacao,
conversaId: args.conversaId,
mensagemId,
remetenteId: usuarioAtual._id,
titulo:
tipoNotificacao === "mencao"
? `${usuarioAtual.nome} mencionou você`
: `Nova mensagem de ${usuarioAtual.nome}`,
descricao: args.conteudo.substring(0, 100),
lida: false,
criadaEm: Date.now(),
});
await ctx.db.insert("notificacoes", {
usuarioId: participanteId,
tipo: tipoNotificacao,
conversaId: args.conversaId,
mensagemId,
remetenteId: usuarioAtual._id,
titulo:
tipoNotificacao === "mencao"
? `${usuarioAtual.nome} mencionou você`
: `Nova mensagem de ${usuarioAtual.nome}`,
descricao: args.conteudo.substring(0, 100),
lida: false,
criadaEm: Date.now(),
});
}
}
} catch (error) {
// Log do erro mas não falhar o envio da mensagem
console.error("Erro ao criar notificações:", error);
// A mensagem já foi criada, então retornamos o ID normalmente
}
return mensagemId;
@@ -264,15 +268,8 @@ export const agendarMensagem = mutation({
agendadaPara: v.number(), // timestamp
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
// Validar data futura
if (args.agendadaPara <= Date.now()) {
@@ -308,15 +305,8 @@ export const cancelarMensagemAgendada = mutation({
mensagemId: v.id("mensagens"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
const mensagem = await ctx.db.get(args.mensagemId);
if (!mensagem) throw new Error("Mensagem não encontrada");
@@ -338,15 +328,8 @@ export const reagirMensagem = mutation({
emoji: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
const mensagem = await ctx.db.get(args.mensagemId);
if (!mensagem) throw new Error("Mensagem não encontrada");
@@ -496,20 +479,13 @@ export const uploadArquivoChat = mutation({
conversaId: v.id("conversas"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
// Verificar se usuário pertence à conversa
const conversa = await ctx.db.get(args.conversaId);
if (!conversa) throw new Error("Conversa não encontrada");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
if (!conversa.participantes.includes(usuarioAtual._id)) {
throw new Error("Você não pertence a esta conversa");
}
@@ -526,8 +502,8 @@ export const marcarNotificacaoLida = mutation({
notificacaoId: v.id("notificacoes"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
await ctx.db.patch(args.notificacaoId, { lida: true });
return true;
@@ -540,15 +516,8 @@ export const marcarNotificacaoLida = mutation({
export const marcarTodasNotificacoesLidas = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
const notificacoes = await ctx.db
.query("notificacoes")
@@ -573,15 +542,8 @@ export const deletarMensagem = mutation({
mensagemId: v.id("mensagens"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Não autenticado");
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!usuarioAtual) throw new Error("Usuário não encontrado");
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) throw new Error("Não autenticado");
const mensagem = await ctx.db.get(args.mensagemId);
if (!mensagem) throw new Error("Mensagem não encontrada");
@@ -607,14 +569,7 @@ export const deletarMensagem = mutation({
export const listarConversas = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
// Buscar todas as conversas do usuário
@@ -641,10 +596,26 @@ export const listarConversas = query({
// Para conversas individuais, pegar o outro usuário
let outroUsuario = null;
if (conversa.tipo === "individual") {
outroUsuario = participantes.find((p) => p?._id !== usuarioAtual._id);
const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id);
if (outroUsuarioRaw) {
// 🔄 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,
};
}
}
}
// Contar mensagens não lidas
// Contar mensagens não lidas (apenas mensagens NÃO agendadas)
const leitura = await ctx.db
.query("leituras")
.withIndex("by_conversa_usuario", (q) =>
@@ -652,11 +623,13 @@ export const listarConversas = query({
)
.first();
const mensagens = await ctx.db
// CORRIGIDO: Buscar apenas mensagens NÃO agendadas (agendadaPara === undefined)
const todasMensagens = await ctx.db
.query("mensagens")
.withIndex("by_conversa", (q) => q.eq("conversaId", conversa._id))
.filter((q) => q.neq(q.field("agendadaPara"), undefined))
.collect();
const mensagens = todasMensagens.filter((m) => !m.agendadaPara);
let naoLidas = 0;
if (leitura) {
@@ -693,14 +666,7 @@ export const obterMensagens = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
// Verificar se usuário pertence à conversa
@@ -747,25 +713,17 @@ export const obterMensagensAgendadas = query({
conversaId: v.id("conversas"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
// Buscar mensagens agendadas
const mensagens = await ctx.db
const todasMensagens = await ctx.db
.query("mensagens")
.withIndex("by_conversa", (q) => q.eq("conversaId", args.conversaId))
.filter((q) => q.neq(q.field("agendadaPara"), undefined))
.collect();
// Filtrar apenas as do usuário atual
const minhasMensagensAgendadas = mensagens.filter(
// Filtrar apenas as agendadas do usuário atual
const minhasMensagensAgendadas = todasMensagens.filter(
(m) =>
m.remetenteId === usuarioAtual._id &&
m.agendadaPara &&
@@ -786,14 +744,7 @@ export const obterNotificacoes = query({
apenasPendentes: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
let query = ctx.db
@@ -834,14 +785,7 @@ export const obterNotificacoes = query({
export const contarNotificacoesNaoLidas = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return 0;
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return 0;
const notificacoes = await ctx.db
@@ -861,8 +805,8 @@ export const contarNotificacoesNaoLidas = query({
export const obterUsuariosOnline = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
const usuarios = await ctx.db
.query("usuarios")
@@ -888,14 +832,7 @@ export const obterUsuariosOnline = query({
export const listarTodosUsuarios = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
const usuarios = await ctx.db
@@ -929,14 +866,7 @@ export const buscarMensagens = query({
conversaId: v.optional(v.id("conversas")),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
// Buscar em todas as conversas do usuário
@@ -1001,14 +931,7 @@ export const obterDigitando = query({
conversaId: v.id("conversas"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return [];
// Buscar indicadores de digitação (últimos 10 segundos)
@@ -1043,14 +966,7 @@ export const contarNaoLidas = query({
conversaId: v.id("conversas"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return 0;
const usuarioAtual = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) return 0;
const leitura = await ctx.db

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

@@ -17,5 +17,29 @@ crons.interval(
internal.chat.limparIndicadoresDigitacao
);
// Atualizar status de férias dos funcionários diariamente
crons.interval(
"atualizar-status-ferias",
{ hours: 24 },
internal.ferias.atualizarStatusTodosFuncionarios,
{}
);
// 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

@@ -0,0 +1,67 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
export const listarPorFuncionario = query({
args: {
funcionarioId: v.id("funcionarios"),
},
returns: v.array(
v.object({
_id: v.id("cursos"),
_creationTime: v.number(),
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
})
),
handler: async (ctx, args) => {
return await ctx.db
.query("cursos")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", args.funcionarioId)
)
.collect();
},
});
export const criar = mutation({
args: {
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
},
returns: v.id("cursos"),
handler: async (ctx, args) => {
const cursoId = await ctx.db.insert("cursos", args);
return cursoId;
},
});
export const atualizar = mutation({
args: {
id: v.id("cursos"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...updates } = args;
await ctx.db.patch(id, updates);
return null;
},
});
export const excluir = mutation({
args: {
id: v.id("cursos"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return null;
},
});

View File

@@ -229,7 +229,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";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodemailer = require("nodemailer");
try {
// Buscar email da fila
@@ -248,7 +250,17 @@ 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.testadoEm) {
return {
sucesso: false,
erro: "Configuração SMTP não foi testada. Teste a conexão primeiro!",
};
}
// Marcar como enviando
@@ -256,15 +268,32 @@ export const enviarEmailAction = action({
emailId: args.emailId,
});
// 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 as any).smtpHost,
port: (config as any).smtpPort,
secure: (config as any).smtpSecure,
auth: {
user: (config as any).smtpUser,
pass: (config as any).smtpPassword,
},
tls: {
rejectUnauthorized: false,
},
});
// Enviar email REAL
const info = await transporter.sendMail({
from: `"${(config as any).remetenteNome}" <${(config as any).remetenteEmail}>`,
to: (email as any).destinatario,
subject: (email as any).assunto,
html: (email as any).corpo,
});
console.log("✅ Email enviado com sucesso!");
console.log(" Para:", (email as any).destinatario);
console.log(" Assunto:", (email as any).assunto);
// Simular delay de envio
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(" Message ID:", info.messageId);
// Marcar como enviado
await ctx.runMutation(internal.email.markEmailEnviado, {
@@ -273,6 +302,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(internal.email.markEmailFalha, {
emailId: args.emailId,
@@ -309,17 +340,19 @@ 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

@@ -0,0 +1,513 @@
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
const periodoValidator = v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
});
// Query: Listar TODAS as solicitações (para RH)
export const listarTodas = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const solicitacoes = await ctx.db.query("solicitacoesFerias").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._creationTime - a._creationTime);
},
});
// Query: Listar solicitações do funcionário
export const listarMinhasSolicitacoes = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
return await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.order("desc")
.collect();
},
});
// Query: Listar solicitações dos subordinados (para gestores)
export const listarSolicitacoesSubordinados = query({
args: { gestorId: v.id("usuarios") },
returns: v.array(v.any()),
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<any> = [];
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("solicitacoesFerias")
.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);
solicitacoes.push({
...s,
funcionario,
time,
});
}
}
}
return solicitacoes.sort((a, b) => b._creationTime - a._creationTime);
},
});
// Query: Obter detalhes completos de uma solicitação
export const obterDetalhes = query({
args: { solicitacaoId: v.id("solicitacoesFerias") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
let gestor = null;
if (solicitacao.gestorId) {
gestor = await ctx.db.get(solicitacao.gestorId);
}
return {
...solicitacao,
funcionario,
gestor,
};
},
});
// Mutation: Criar solicitação de férias (com validação de saldo)
export const criarSolicitacao = mutation({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
periodos: v.array(periodoValidator),
observacao: v.optional(v.string()),
},
returns: v.id("solicitacoesFerias"),
handler: async (ctx, args) => {
if (args.periodos.length === 0) {
throw new Error("É necessário adicionar pelo menos 1 período");
}
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) throw new Error("Funcionário não encontrado");
// 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")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", args.funcionarioId))
.first();
const solicitacaoId = await ctx.db.insert("solicitacoesFerias", {
funcionarioId: args.funcionarioId,
anoReferencia: args.anoReferencia,
status: "aguardando_aprovacao",
periodos: args.periodos,
observacao: args.observacao,
historicoAlteracoes: [{
data: Date.now(),
usuarioId: usuario?._id || funcionario.gestorId!,
acao: "Solicitação criada",
}],
});
// Notificar gestor
if (funcionario.gestorId) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: funcionario.gestorId,
solicitacaoFeriasId: solicitacaoId,
tipo: "nova_solicitacao",
lida: false,
mensagem: `${funcionario.nome} solicitou férias`,
});
}
return solicitacaoId;
},
});
// Mutation: Aprovar férias
export const aprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
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");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
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",
},
],
});
// 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
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: usuario._id,
solicitacaoFeriasId: args.solicitacaoId,
tipo: "aprovado",
lida: false,
mensagem: "Suas férias foram aprovadas!",
});
}
}
return null;
},
});
// Mutation: Reprovar férias
export const reprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
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");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
await ctx.db.patch(args.solicitacaoId, {
status: "reprovado",
gestorId: args.gestorId,
dataReprovacao: Date.now(),
motivoReprovacao: args.motivoReprovacao,
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: `Reprovado: ${args.motivoReprovacao}`,
},
],
});
// 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
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: usuario._id,
solicitacaoFeriasId: args.solicitacaoId,
tipo: "reprovado",
lida: false,
mensagem: `Suas férias foram reprovadas: ${args.motivoReprovacao}`,
});
}
}
return null;
},
});
// Mutation: Ajustar data e aprovar
export const ajustarEAprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
gestorId: v.id("usuarios"),
novosPeriodos: v.array(periodoValidator),
},
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");
}
if (args.novosPeriodos.length === 0) {
throw new Error("É necessário adicionar pelo menos 1 período");
}
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;
}
await ctx.runMutation(internal.saldoFerias.reservarDias, {
funcionarioId: solicitacao.funcionarioId,
anoReferencia: solicitacao.anoReferencia,
totalDias: totalNovosDias,
});
await ctx.db.patch(args.solicitacaoId, {
status: "data_ajustada_aprovada",
periodos: args.novosPeriodos,
gestorId: args.gestorId,
dataAprovacao: Date.now(),
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: "Data ajustada e aprovada",
periodosAnteriores: solicitacao.periodos,
},
],
});
// Atualizar saldo (marcar como usado)
await ctx.runMutation(internal.saldoFerias.atualizarSaldoAposAprovacao, {
solicitacaoId: args.solicitacaoId,
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: usuario._id,
solicitacaoFeriasId: args.solicitacaoId,
tipo: "data_ajustada",
lida: false,
mensagem: "Suas férias foram aprovadas com ajuste de datas",
});
}
}
return null;
},
});
// Query: Verificar status de férias automático
export const verificarStatusFerias = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.union(v.literal("ativo"), v.literal("em_ferias")),
handler: async (ctx, args) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const solicitacoesAprovadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", args.funcionarioId)
.eq("status", "aprovado")
)
.collect();
const solicitacoesAjustadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", args.funcionarioId)
.eq("status", "data_ajustada_aprovada")
)
.collect();
const todasSolicitacoes = [...solicitacoesAprovadas, ...solicitacoesAjustadas];
for (const solicitacao of todasSolicitacoes) {
for (const periodo of solicitacao.periodos) {
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(23, 59, 59, 999);
if (hoje >= inicio && hoje <= fim) {
return "em_ferias";
}
}
}
return "ativo";
},
});
// Query: Obter notificações não lidas
export const obterNotificacoesNaoLidas = query({
args: { usuarioId: v.id("usuarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
return await ctx.db
.query("notificacoesFerias")
.withIndex("by_destinatario_and_lida", (q) =>
q.eq("destinatarioId", args.usuarioId).eq("lida", false)
)
.collect();
},
});
// Mutation: Marcar notificação como lida
export const marcarComoLida = mutation({
args: { notificacaoId: v.id("notificacoesFerias") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.notificacaoId, { lida: true });
return null;
},
});
// Internal Mutation: Atualizar status de todos os funcionários
export const atualizarStatusTodosFuncionarios = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
for (const func of funcionarios) {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const solicitacoesAprovadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", func._id)
.eq("status", "aprovado")
)
.collect();
const solicitacoesAjustadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", func._id)
.eq("status", "data_ajustada_aprovada")
)
.collect();
const todasSolicitacoes = [...solicitacoesAprovadas, ...solicitacoesAjustadas];
let emFerias = false;
for (const solicitacao of todasSolicitacoes) {
for (const periodo of solicitacao.periodos) {
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(23, 59, 59, 999);
if (hoje >= inicio && hoje <= fim) {
emFerias = true;
break;
}
}
if (emFerias) break;
}
const novoStatus = emFerias ? "em_ferias" : "ativo";
if (func.statusFerias !== novoStatus) {
await ctx.db.patch(func._id, { statusFerias: novoStatus });
}
}
return null;
},
});

View File

@@ -38,6 +38,7 @@ const aposentadoValidator = v.optional(
export const getAll = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
// Autorização: listar funcionários
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
@@ -70,6 +71,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) => {
// Autorização: ver funcionário
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
@@ -84,7 +86,7 @@ export const create = mutation({
args: {
// Campos obrigatórios
nome: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
simboloId: v.id("simbolos"),
nascimento: v.string(),
rg: v.string(),
@@ -190,13 +192,15 @@ export const create = mutation({
throw new Error("CPF já cadastrado");
}
// Unicidade: Matrícula
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists) {
throw new Error("Matrícula já cadastrada");
// Unicidade: Matrícula (apenas se fornecida)
if (args.matricula) {
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists) {
throw new Error("Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco.");
}
}
const novoFuncionarioId = await ctx.db.insert("funcionarios", args as any);
@@ -209,7 +213,7 @@ export const update = mutation({
id: v.id("funcionarios"),
// Campos obrigatórios
nome: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
simboloId: v.id("simbolos"),
nascimento: v.string(),
rg: v.string(),
@@ -315,13 +319,15 @@ export const update = mutation({
throw new Error("CPF já cadastrado");
}
// Unicidade: Matrícula (excluindo o próprio registro)
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists && matriculaExists._id !== args.id) {
throw new Error("Matrícula já cadastrada");
// Unicidade: Matrícula (apenas se fornecida, excluindo o próprio registro)
if (args.matricula) {
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists && matriculaExists._id !== args.id) {
throw new Error("Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco.");
}
}
const { id, ...updateData } = args;
@@ -348,6 +354,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) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "funcionarios",
@@ -361,15 +368,55 @@ export const getFichaCompleta = query({
// Buscar informações do símbolo
const simbolo = await ctx.db.get(funcionario.simboloId);
// Buscar cursos do funcionário
const cursos = await ctx.db
.query("cursos")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.id))
.collect();
// Buscar URLs dos certificados
const cursosComUrls = await Promise.all(
cursos.map(async (curso) => {
let certificadoUrl = null;
if (curso.certificadoId) {
certificadoUrl = await ctx.storage.getUrl(curso.certificadoId);
}
return {
...curso,
certificadoUrl,
};
})
);
return {
...funcionario,
simbolo: simbolo
? {
nome: simbolo.nome,
descricao: simbolo.descricao,
valor: simbolo.valor,
// campos adicionais, se existirem no símbolo
tipo: (simbolo as any).tipo,
vencValor: (simbolo as any).vencValor,
repValor: (simbolo as any).repValor,
valor: (simbolo as any).valor,
}
: null,
cursos: cursosComUrls,
};
},
});
// Mutation: Configurar gestor (apenas para TI_MASTER)
export const configurarGestor = mutation({
args: {
funcionarioId: v.id("funcionarios"),
gestorId: v.optional(v.id("usuarios")),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.funcionarioId, {
gestorId: args.gestorId,
});
return null;
},
});

View File

@@ -0,0 +1,171 @@
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
/**
* Migração: Converte estrutura antiga de gestores individuais para times
*
* Esta função cria automaticamente times baseados nos gestores existentes
* e adiciona os funcionários subordinados aos respectivos times.
*
* Execute uma vez via dashboard do Convex:
* Settings > Functions > Internal > migrarParaTimes > executar
*/
export const executar = internalMutation({
args: {},
returns: v.object({
timesCreated: v.number(),
funcionariosAtribuidos: v.number(),
erros: v.array(v.string()),
}),
handler: async (ctx) => {
const erros: string[] = [];
let timesCreated = 0;
let funcionariosAtribuidos = 0;
try {
// 1. Buscar todos os funcionários que têm gestor definido
const funcionariosComGestor = await ctx.db
.query("funcionarios")
.filter((q) => q.neq(q.field("gestorId"), undefined))
.collect();
if (funcionariosComGestor.length === 0) {
return {
timesCreated: 0,
funcionariosAtribuidos: 0,
erros: ["Nenhum funcionário com gestor configurado encontrado"],
};
}
// 2. Agrupar funcionários por gestor
const gestoresMap = new Map<string, any[]>();
for (const funcionario of funcionariosComGestor) {
if (!funcionario.gestorId) continue;
const gestorId = funcionario.gestorId;
if (!gestoresMap.has(gestorId)) {
gestoresMap.set(gestorId, []);
}
gestoresMap.get(gestorId)!.push(funcionario);
}
// 3. Para cada gestor, criar um time
for (const [gestorId, subordinados] of gestoresMap.entries()) {
try {
const gestor = await ctx.db.get(gestorId as any);
if (!gestor) {
erros.push(`Gestor ${gestorId} não encontrado`);
continue;
}
// Verificar se já existe time para este gestor
const timeExistente = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", gestorId as any))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let timeId;
if (timeExistente) {
timeId = timeExistente._id;
} else {
// Criar novo time
timeId = await ctx.db.insert("times", {
nome: `Equipe ${gestor.nome}`,
descricao: `Time gerenciado por ${gestor.nome} (migração automática)`,
gestorId: gestorId as any,
ativo: true,
cor: "#3B82F6",
});
timesCreated++;
}
// Adicionar membros ao time
for (const funcionario of subordinados) {
try {
// Verificar se já está em algum time
const membroExistente = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", funcionario._id))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (!membroExistente) {
await ctx.db.insert("timesMembros", {
timeId: timeId,
funcionarioId: funcionario._id,
dataEntrada: Date.now(),
ativo: true,
});
funcionariosAtribuidos++;
}
} catch (e: any) {
erros.push(`Erro ao adicionar ${funcionario.nome} ao time: ${e.message}`);
}
}
} catch (e: any) {
erros.push(`Erro ao processar gestor ${gestorId}: ${e.message}`);
}
}
return {
timesCreated,
funcionariosAtribuidos,
erros,
};
} catch (e: any) {
erros.push(`Erro geral na migração: ${e.message}`);
return {
timesCreated,
funcionariosAtribuidos,
erros,
};
}
},
});
/**
* Função auxiliar para limpar times inativos antigos
*/
export const limparTimesInativos = internalMutation({
args: {
diasInativos: v.optional(v.number()),
},
returns: v.number(),
handler: async (ctx, args) => {
const diasLimite = args.diasInativos || 30;
const dataLimite = Date.now() - (diasLimite * 24 * 60 * 60 * 1000);
const timesInativos = await ctx.db
.query("times")
.filter((q) => q.eq(q.field("ativo"), false))
.collect();
let removidos = 0;
for (const time of timesInativos) {
if (time._creationTime < dataLimite) {
// Remover membros inativos do time
const membrosInativos = await ctx.db
.query("timesMembros")
.withIndex("by_time", (q) => q.eq("timeId", time._id))
.filter((q) => q.eq(q.field("ativo"), false))
.collect();
for (const membro of membrosInativos) {
await ctx.db.delete(membro._id);
}
// Remover o time
await ctx.db.delete(time._id);
removidos++;
}
}
return removidos;
},
});

View File

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

View File

@@ -1,146 +1,562 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { Id } from "./_generated/dataModel";
/**
* Obter estatísticas em tempo real do sistema
* Helper para obter usuário autenticado
*/
export const getStatusSistema = query({
args: {},
async function getUsuarioAutenticado(ctx: any) {
const usuariosOnline = await ctx.db.query("usuarios").collect();
const usuarioOnline = usuariosOnline.find(
(u: any) => u.statusPresenca === "online"
);
return usuarioOnline || null;
}
/**
* Salvar métricas do sistema
*/
export const salvarMetricas = mutation({
args: {
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
networkLatency: v.optional(v.number()),
storageUsed: v.optional(v.number()),
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
},
returns: v.object({
usuariosOnline: v.number(),
totalRegistros: v.number(),
tempoMedioResposta: v.number(),
memoriaUsada: v.number(),
cpuUsada: v.number(),
ultimaAtualizacao: v.number(),
success: v.boolean(),
metricId: v.optional(v.id("systemMetrics")),
}),
handler: async (ctx) => {
// Contar usuários online (sessões ativas nos últimos 5 minutos)
const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
const sessoesAtivas = await ctx.db
.query("sessoes")
.filter((q) =>
q.and(
q.eq(q.field("ativo"), true),
q.gt(q.field("criadoEm"), cincoMinutosAtras)
)
)
handler: async (ctx, args) => {
const timestamp = Date.now();
// Salvar métricas
const metricId = await ctx.db.insert("systemMetrics", {
timestamp,
cpuUsage: args.cpuUsage,
memoryUsage: args.memoryUsage,
networkLatency: args.networkLatency,
storageUsed: args.storageUsed,
usuariosOnline: args.usuariosOnline,
mensagensPorMinuto: args.mensagensPorMinuto,
tempoRespostaMedio: args.tempoRespostaMedio,
errosCount: args.errosCount,
});
// Verificar alertas após salvar métricas
await ctx.scheduler.runAfter(0, internal.monitoramento.verificarAlertasInternal, {
metricId,
});
// Limpar métricas antigas (mais de 30 dias)
const dataLimite = Date.now() - 30 * 24 * 60 * 60 * 1000;
const metricasAntigas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) => q.lt("timestamp", dataLimite))
.collect();
const usuariosOnline = sessoesAtivas.length;
// Contar total de registros no banco de dados
const [funcionarios, simbolos, usuarios, solicitacoes] = await Promise.all([
ctx.db.query("funcionarios").collect(),
ctx.db.query("simbolos").collect(),
ctx.db.query("usuarios").collect(),
ctx.db.query("solicitacoesAcesso").collect(),
]);
const totalRegistros = funcionarios.length + simbolos.length + usuarios.length + solicitacoes.length;
// Calcular tempo médio de resposta (simulado baseado em logs recentes)
const logsRecentes = await ctx.db
.query("logsAcesso")
.order("desc")
.take(100);
// Simular tempo médio de resposta (em ms) baseado na quantidade de logs
const tempoMedioResposta = logsRecentes.length > 0
? Math.round(50 + Math.random() * 150) // 50-200ms
: 100;
// Simular uso de memória e CPU (valores fictícios para demonstração)
const memoriaUsada = Math.round(45 + Math.random() * 15); // 45-60%
const cpuUsada = Math.round(20 + Math.random() * 30); // 20-50%
for (const metrica of metricasAntigas) {
await ctx.db.delete(metrica._id);
}
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
memoriaUsada,
cpuUsada,
ultimaAtualizacao: Date.now(),
success: true,
metricId,
};
},
});
/**
* Obter histórico de atividades do banco de dados (últimos 60 segundos)
* Configurar ou atualizar alerta
*/
export const getAtividadeBancoDados = query({
args: {},
returns: v.object({
historico: v.array(
v.object({
timestamp: v.number(),
entradas: v.number(),
saidas: v.number(),
})
export const configurarAlerta = mutation({
args: {
alertId: v.optional(v.id("alertConfigurations")),
metricName: v.string(),
threshold: v.number(),
operator: v.union(
v.literal(">"),
v.literal("<"),
v.literal(">="),
v.literal("<="),
v.literal("==")
),
enabled: v.boolean(),
notifyByEmail: v.boolean(),
notifyByChat: v.boolean(),
},
returns: v.object({
success: v.boolean(),
alertId: v.id("alertConfigurations"),
}),
handler: async (ctx) => {
const agora = Date.now();
const umMinutoAtras = agora - 60 * 1000;
handler: async (ctx, args) => {
const usuario = await getUsuarioAutenticado(ctx);
if (!usuario) {
throw new Error("Não autenticado");
}
// Obter logs de acesso do último minuto
const logsRecentes = await ctx.db
.query("logsAcesso")
.filter((q) => q.gt(q.field("timestamp"), umMinutoAtras))
.collect();
let alertId: Id<"alertConfigurations">;
// Agrupar por segundos (intervalos de 5 segundos para suavizar)
const historico: Array<{ timestamp: number; entradas: number; saidas: number }> = [];
for (let i = 0; i < 12; i++) {
const timestampInicio = umMinutoAtras + i * 5000;
const timestampFim = timestampInicio + 5000;
const logsNoIntervalo = logsRecentes.filter(
(log) => log.timestamp >= timestampInicio && log.timestamp < timestampFim
);
const entradas = logsNoIntervalo.filter((log) => log.tipo === "login").length;
const saidas = logsNoIntervalo.filter((log) => log.tipo === "logout").length;
historico.push({
timestamp: timestampInicio,
entradas: entradas + Math.round(Math.random() * 3), // Adicionar variação simulada
saidas: saidas + Math.round(Math.random() * 2),
if (args.alertId) {
// Atualizar alerta existente
await ctx.db.patch(args.alertId, {
metricName: args.metricName,
threshold: args.threshold,
operator: args.operator,
enabled: args.enabled,
notifyByEmail: args.notifyByEmail,
notifyByChat: args.notifyByChat,
lastModified: Date.now(),
});
alertId = args.alertId;
} else {
// Criar novo alerta
alertId = await ctx.db.insert("alertConfigurations", {
metricName: args.metricName,
threshold: args.threshold,
operator: args.operator,
enabled: args.enabled,
notifyByEmail: args.notifyByEmail,
notifyByChat: args.notifyByChat,
createdBy: usuario._id,
lastModified: Date.now(),
});
}
return { historico };
},
});
/**
* Obter distribuição de tipos de requisições
*/
export const getDistribuicaoRequisicoes = query({
args: {},
returns: v.object({
queries: v.number(),
mutations: v.number(),
leituras: v.number(),
escritas: v.number(),
}),
handler: async (ctx) => {
const logs = await ctx.db
.query("logsAcesso")
.order("desc")
.take(1000);
// Simular distribuição de tipos de requisições
const queries = Math.round(logs.length * 0.6 + Math.random() * 50);
const mutations = Math.round(logs.length * 0.3 + Math.random() * 30);
const leituras = Math.round(logs.length * 0.7 + Math.random() * 40);
const escritas = Math.round(logs.length * 0.3 + Math.random() * 20);
return {
queries,
mutations,
leituras,
escritas,
success: true,
alertId,
};
},
});
/**
* Listar todas as configurações de alerta
*/
export const listarAlertas = query({
args: {},
returns: v.array(
v.object({
_id: v.id("alertConfigurations"),
metricName: v.string(),
threshold: v.number(),
operator: v.union(
v.literal(">"),
v.literal("<"),
v.literal(">="),
v.literal("<="),
v.literal("==")
),
enabled: v.boolean(),
notifyByEmail: v.boolean(),
notifyByChat: v.boolean(),
createdBy: v.id("usuarios"),
lastModified: v.number(),
})
),
handler: async (ctx) => {
const alertas = await ctx.db.query("alertConfigurations").collect();
return alertas;
},
});
/**
* Obter métricas com filtros
*/
export const obterMetricas = query({
args: {
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number()),
metricName: v.optional(v.string()),
limit: v.optional(v.number()),
},
returns: v.array(
v.object({
_id: v.id("systemMetrics"),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
networkLatency: v.optional(v.number()),
storageUsed: v.optional(v.number()),
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
})
),
handler: async (ctx, args) => {
let query = ctx.db.query("systemMetrics");
// Filtrar por data se fornecido
if (args.dataInicio !== undefined || args.dataFim !== undefined) {
query = query.withIndex("by_timestamp", (q) => {
if (args.dataInicio !== undefined && args.dataFim !== undefined) {
return q.gte("timestamp", args.dataInicio).lte("timestamp", args.dataFim);
} else if (args.dataInicio !== undefined) {
return q.gte("timestamp", args.dataInicio);
} else {
return q.lte("timestamp", args.dataFim!);
}
});
}
let metricas = await query.order("desc").collect();
// Limitar resultados
if (args.limit !== undefined && args.limit > 0) {
metricas = metricas.slice(0, args.limit);
}
return metricas;
},
});
/**
* Obter métricas mais recentes (última hora)
*/
export const obterMetricasRecentes = query({
args: {},
returns: v.array(
v.object({
_id: v.id("systemMetrics"),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
networkLatency: v.optional(v.number()),
storageUsed: v.optional(v.number()),
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
})
),
handler: async (ctx) => {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
const metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) => q.gte("timestamp", umaHoraAtras))
.order("desc")
.take(100);
return metricas;
},
});
/**
* Obter última métrica salva
*/
export const obterUltimaMetrica = query({
args: {},
returns: v.union(
v.object({
_id: v.id("systemMetrics"),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
networkLatency: v.optional(v.number()),
storageUsed: v.optional(v.number()),
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
}),
v.null()
),
handler: async (ctx) => {
const metrica = await ctx.db
.query("systemMetrics")
.order("desc")
.first();
return metrica || null;
},
});
/**
* Verificar alertas (internal)
*/
export const verificarAlertasInternal = internalMutation({
args: {
metricId: v.id("systemMetrics"),
},
returns: v.null(),
handler: async (ctx, args) => {
const metrica = await ctx.db.get(args.metricId);
if (!metrica) return null;
// Buscar configurações de alerta ativas
const alertasAtivos = await ctx.db
.query("alertConfigurations")
.withIndex("by_enabled", (q) => q.eq("enabled", true))
.collect();
for (const alerta of alertasAtivos) {
// Obter valor da métrica correspondente
const metricValue = (metrica as any)[alerta.metricName];
if (metricValue === undefined) continue;
// Verificar se o alerta deve ser disparado
let shouldTrigger = false;
switch (alerta.operator) {
case ">":
shouldTrigger = metricValue > alerta.threshold;
break;
case "<":
shouldTrigger = metricValue < alerta.threshold;
break;
case ">=":
shouldTrigger = metricValue >= alerta.threshold;
break;
case "<=":
shouldTrigger = metricValue <= alerta.threshold;
break;
case "==":
shouldTrigger = metricValue === alerta.threshold;
break;
}
if (shouldTrigger) {
// Verificar se já existe um alerta triggered recente (últimos 5 minutos)
const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
const alertaRecente = await ctx.db
.query("alertHistory")
.withIndex("by_config", (q) =>
q.eq("configId", alerta._id).gte("timestamp", cincoMinutosAtras)
)
.filter((q) => q.eq(q.field("status"), "triggered"))
.first();
// Se já existe alerta recente, não disparar novamente
if (alertaRecente) continue;
// Registrar alerta no histórico
await ctx.db.insert("alertHistory", {
configId: alerta._id,
metricName: alerta.metricName,
metricValue,
threshold: alerta.threshold,
timestamp: Date.now(),
status: "triggered",
notificationsSent: {
email: alerta.notifyByEmail,
chat: alerta.notifyByChat,
},
});
// Criar notificação no chat se configurado
if (alerta.notifyByChat) {
// Buscar usuários TI para notificar
const usuarios = await ctx.db.query("usuarios").collect();
const usuariosTI = usuarios.filter(
(u: any) => u.role?.nome === "ti" || u.role?.nivel === 0
);
for (const usuario of usuariosTI) {
await ctx.db.insert("notificacoes", {
usuarioId: usuario._id,
tipo: "nova_mensagem",
titulo: `⚠️ Alerta de Sistema: ${alerta.metricName}`,
descricao: `Métrica ${alerta.metricName} está em ${metricValue.toFixed(2)}% (limite: ${alerta.threshold}%)`,
lida: false,
criadaEm: Date.now(),
});
}
}
// TODO: Enviar email se configurado (integração com sistema de email)
// if (alerta.notifyByEmail) {
// await enviarEmailAlerta(alerta, metricValue);
// }
}
}
return null;
},
});
/**
* Gerar relatório de métricas
*/
export const gerarRelatorio = query({
args: {
dataInicio: v.number(),
dataFim: v.number(),
metricNames: v.optional(v.array(v.string())),
},
returns: v.object({
periodo: v.object({
inicio: v.number(),
fim: v.number(),
}),
metricas: v.array(
v.object({
_id: v.id("systemMetrics"),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
networkLatency: v.optional(v.number()),
storageUsed: v.optional(v.number()),
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
})
),
estatisticas: v.object({
cpuUsage: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
memoryUsage: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
networkLatency: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
storageUsed: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
usuariosOnline: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
mensagensPorMinuto: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
tempoRespostaMedio: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
errosCount: v.optional(v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
}),
}),
handler: async (ctx, args) => {
// Buscar métricas no período
const metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) =>
q.gte("timestamp", args.dataInicio).lte("timestamp", args.dataFim)
)
.collect();
// Calcular estatísticas
const calcularEstatisticas = (
valores: number[]
): { min: number; max: number; avg: number } | undefined => {
if (valores.length === 0) return undefined;
return {
min: Math.min(...valores),
max: Math.max(...valores),
avg: valores.reduce((a, b) => a + b, 0) / valores.length,
};
};
const estatisticas = {
cpuUsage: calcularEstatisticas(
metricas.map((m) => m.cpuUsage).filter((v) => v !== undefined) as number[]
),
memoryUsage: calcularEstatisticas(
metricas.map((m) => m.memoryUsage).filter((v) => v !== undefined) as number[]
),
networkLatency: calcularEstatisticas(
metricas.map((m) => m.networkLatency).filter((v) => v !== undefined) as number[]
),
storageUsed: calcularEstatisticas(
metricas.map((m) => m.storageUsed).filter((v) => v !== undefined) as number[]
),
usuariosOnline: calcularEstatisticas(
metricas.map((m) => m.usuariosOnline).filter((v) => v !== undefined) as number[]
),
mensagensPorMinuto: calcularEstatisticas(
metricas.map((m) => m.mensagensPorMinuto).filter((v) => v !== undefined) as number[]
),
tempoRespostaMedio: calcularEstatisticas(
metricas.map((m) => m.tempoRespostaMedio).filter((v) => v !== undefined) as number[]
),
errosCount: calcularEstatisticas(
metricas.map((m) => m.errosCount).filter((v) => v !== undefined) as number[]
),
};
return {
periodo: {
inicio: args.dataInicio,
fim: args.dataFim,
},
metricas,
estatisticas,
};
},
});
/**
* Deletar configuração de alerta
*/
export const deletarAlerta = mutation({
args: {
alertId: v.id("alertConfigurations"),
},
returns: v.object({
success: v.boolean(),
}),
handler: async (ctx, args) => {
await ctx.db.delete(args.alertId);
return { success: true };
},
});
/**
* Obter histórico de alertas
*/
export const obterHistoricoAlertas = query({
args: {
limit: v.optional(v.number()),
},
returns: v.array(
v.object({
_id: v.id("alertHistory"),
configId: v.id("alertConfigurations"),
metricName: v.string(),
metricValue: v.number(),
threshold: v.number(),
timestamp: v.number(),
status: v.union(v.literal("triggered"), v.literal("resolved")),
notificationsSent: v.object({
email: v.boolean(),
chat: v.boolean(),
}),
})
),
handler: async (ctx, args) => {
const limit = args.limit || 50;
const historico = await ctx.db
.query("alertHistory")
.order("desc")
.take(limit);
return historico;
},
});

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

@@ -24,11 +24,24 @@ export default defineSchema({
uf: v.string(),
telefone: v.string(),
email: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
admissaoData: v.optional(v.string()),
desligamentoData: v.optional(v.string()),
simboloId: v.id("simbolos"),
simboloTipo: simboloTipo,
gestorId: v.optional(v.id("usuarios")),
statusFerias: v.optional(v.union(
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()),
@@ -133,7 +146,8 @@ export default defineSchema({
.index("by_simboloId", ["simboloId"])
.index("by_simboloTipo", ["simboloTipo"])
.index("by_cpf", ["cpf"])
.index("by_rg", ["rg"]),
.index("by_rg", ["rg"])
.index("by_gestor", ["gestorId"]),
atestados: defineTable({
funcionarioId: v.id("funcionarios"),
@@ -143,11 +157,109 @@ export default defineSchema({
descricao: v.string(),
}),
ferias: defineTable({
solicitacoesFerias: defineTable({
funcionarioId: v.id("funcionarios"),
dataInicio: v.string(),
dataFim: v.string(),
}),
anoReferencia: v.number(),
status: v.union(
v.literal("aguardando_aprovacao"),
v.literal("aprovado"),
v.literal("reprovado"),
v.literal("data_ajustada_aprovada")
),
periodos: v.array(
v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
})
),
observacao: v.optional(v.string()),
motivoReprovacao: v.optional(v.string()),
gestorId: v.optional(v.id("usuarios")),
dataAprovacao: v.optional(v.number()),
dataReprovacao: v.optional(v.number()),
historicoAlteracoes: v.optional(
v.array(
v.object({
data: v.number(),
usuarioId: v.id("usuarios"),
acao: v.string(),
periodosAnteriores: v.optional(v.array(v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
}))),
})
)
),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_status", ["status"])
.index("by_funcionario_and_status", ["funcionarioId", "status"])
.index("by_ano", ["anoReferencia"]),
notificacoesFerias: defineTable({
destinatarioId: v.id("usuarios"),
solicitacaoFeriasId: v.id("solicitacoesFerias"),
tipo: v.union(
v.literal("nova_solicitacao"),
v.literal("aprovado"),
v.literal("reprovado"),
v.literal("data_ajustada")
),
lida: v.boolean(),
mensagem: v.string(),
})
.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()),
gestorId: v.id("usuarios"),
ativo: v.boolean(),
cor: v.optional(v.string()), // Cor para identificação visual
}).index("by_gestor", ["gestorId"]),
timesMembros: defineTable({
timeId: v.id("times"),
funcionarioId: v.id("funcionarios"),
dataEntrada: v.number(),
dataSaida: v.optional(v.number()),
ativo: v.boolean(),
})
.index("by_time", ["timeId"])
.index("by_funcionario", ["funcionarioId"])
.index("by_time_and_ativo", ["timeId", "ativo"]),
cursos: defineTable({
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
}).index("by_funcionario", ["funcionarioId"]),
simbolos: defineTable({
nome: v.string(),
@@ -220,7 +332,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"
@@ -520,4 +633,54 @@ export default defineSchema({
})
.index("by_conversa", ["conversaId", "iniciouEm"])
.index("by_usuario", ["usuarioId"]),
// Tabelas de Monitoramento do Sistema
systemMetrics: defineTable({
timestamp: v.number(),
// Métricas de Sistema
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
networkLatency: v.optional(v.number()),
storageUsed: v.optional(v.number()),
// Métricas de Aplicação
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
})
.index("by_timestamp", ["timestamp"]),
alertConfigurations: defineTable({
metricName: v.string(),
threshold: v.number(),
operator: v.union(
v.literal(">"),
v.literal("<"),
v.literal(">="),
v.literal("<="),
v.literal("==")
),
enabled: v.boolean(),
notifyByEmail: v.boolean(),
notifyByChat: v.boolean(),
createdBy: v.id("usuarios"),
lastModified: v.number(),
})
.index("by_enabled", ["enabled"]),
alertHistory: defineTable({
configId: v.id("alertConfigurations"),
metricName: v.string(),
metricValue: v.number(),
threshold: v.number(),
timestamp: v.number(),
status: v.union(v.literal("triggered"), v.literal("resolved")),
notificationsSent: v.object({
email: v.boolean(),
chat: v.boolean(),
}),
})
.index("by_timestamp", ["timestamp"])
.index("by_status", ["status"])
.index("by_config", ["configId", "timestamp"]),
});

View File

@@ -0,0 +1,270 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
// Query: Listar todos os times
export const listar = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const times = await ctx.db.query("times").collect();
// Buscar gestor e contar membros de cada time
const timesComDetalhes = await Promise.all(
times.map(async (time) => {
const gestor = await ctx.db.get(time.gestorId);
const membrosAtivos = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
return {
...time,
gestor,
totalMembros: membrosAtivos.length,
};
})
);
return timesComDetalhes;
},
});
// Query: Obter time por ID com membros
export const obterPorId = query({
args: { id: v.id("times") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const time = await ctx.db.get(args.id);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", args.id).eq("ativo", true))
.collect();
// Buscar dados completos dos membros
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario,
};
})
);
return {
...time,
gestor,
membros,
};
},
});
// Query: Obter time do funcionário
export const obterTimeFuncionario = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const relacao = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (!relacao) return null;
const time = await ctx.db.get(relacao.timeId);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
return {
...time,
gestor,
};
},
});
// Query: Obter times do gestor
export const listarPorGestor = query({
args: { gestorId: v.id("usuarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
const times = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
const timesComMembros = await Promise.all(
times.map(async (time) => {
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario,
};
})
);
return {
...time,
membros,
};
})
);
return timesComMembros;
},
});
// Mutation: Criar time
export const criar = mutation({
args: {
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
cor: v.optional(v.string()),
},
returns: v.id("times"),
handler: async (ctx, args) => {
const timeId = await ctx.db.insert("times", {
nome: args.nome,
descricao: args.descricao,
gestorId: args.gestorId,
ativo: true,
cor: args.cor || "#3B82F6",
});
return timeId;
},
});
// Mutation: Atualizar time
export const atualizar = mutation({
args: {
id: v.id("times"),
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
cor: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...dados } = args;
await ctx.db.patch(id, dados);
return null;
},
});
// Mutation: Desativar time
export const desativar = mutation({
args: { id: v.id("times") },
returns: v.null(),
handler: async (ctx, args) => {
// Desativar o time
await ctx.db.patch(args.id, { ativo: false });
// Desativar todos os membros
const membros = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", args.id).eq("ativo", true))
.collect();
for (const membro of membros) {
await ctx.db.patch(membro._id, {
ativo: false,
dataSaida: Date.now(),
});
}
return null;
},
});
// Mutation: Adicionar membro ao time
export const adicionarMembro = mutation({
args: {
timeId: v.id("times"),
funcionarioId: v.id("funcionarios"),
},
returns: v.id("timesMembros"),
handler: async (ctx, args) => {
// Verificar se já não está em outro time ativo
const membroExistente = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (membroExistente) {
throw new Error("Funcionário já está em um time ativo");
}
const membroId = await ctx.db.insert("timesMembros", {
timeId: args.timeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
return membroId;
},
});
// Mutation: Remover membro do time
export const removerMembro = mutation({
args: { membroId: v.id("timesMembros") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.membroId, {
ativo: false,
dataSaida: Date.now(),
});
return null;
},
});
// Mutation: Transferir membro para outro time
export const transferirMembro = mutation({
args: {
funcionarioId: v.id("funcionarios"),
novoTimeId: v.id("times"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Desativar do time atual
const relacaoAtual = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (relacaoAtual) {
await ctx.db.patch(relacaoAtual._id, {
ativo: false,
dataSaida: Date.now(),
});
}
// Adicionar ao novo time
await ctx.db.insert("timesMembros", {
timeId: args.novoTimeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
return null;
},
});

View File

@@ -5,6 +5,62 @@ import { registrarAtividade } from "./logsAtividades";
import { Id } from "./_generated/dataModel";
import { api } from "./_generated/api";
/**
* 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)
*/
@@ -413,6 +469,7 @@ export const obterPerfil = query({
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()),
@@ -500,6 +557,7 @@ export const obterPerfil = query({
nome: usuarioAtual.nome,
email: usuarioAtual.email,
matricula: usuarioAtual.matricula,
funcionarioId: usuarioAtual.funcionarioId,
avatar: usuarioAtual.avatar,
fotoPerfil: usuarioAtual.fotoPerfil,
fotoPerfilUrl,
@@ -522,7 +580,7 @@ export const listarParaChat = query({
_id: v.id("usuarios"),
nome: v.string(),
email: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
avatar: v.optional(v.string()),
fotoPerfil: v.optional(v.id("_storage")),
fotoPerfilUrl: v.union(v.string(), v.null()),
@@ -558,7 +616,7 @@ export const listarParaChat = query({
_id: usuario._id,
nome: usuario.nome,
email: usuario.email,
matricula: usuario.matricula,
matricula: usuario.matricula || undefined,
avatar: usuario.avatar,
fotoPerfil: usuario.fotoPerfil,
fotoPerfilUrl,

View File

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

View File

@@ -9,11 +9,19 @@
"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": {
"@dicebear/avataaars": "^9.2.4",
"convex": "^1.28.0"
"convex": "^1.28.0",
"nodemailer": "^7.0.10"
}
}