diff --git a/apps/web/src/routes/(dashboard)/ti/times/+page.svelte b/apps/web/src/routes/(dashboard)/ti/times/+page.svelte
new file mode 100644
index 0000000..6f2aa89
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/ti/times/+page.svelte
@@ -0,0 +1,505 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Gestão de Times
+
Organize funcionários em equipes e defina gestores
+
+
+
+
+
+
+
+
+
+
+ {#if modoEdicao}
+
+
+
+ {timeEmEdicao ? "Editar Time" : "Novo Time"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ {#each times as time}
+ {#if time.ativo}
+
+
+
+
{time.nome}
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
{time.descricao || "Sem descrição"}
+
+
+
+
+
+
+
Gestor: {time.gestor?.nome}
+
+
+
+
Membros: {time.totalMembros || 0}
+
+
+
+
+ {/if}
+ {/each}
+
+ {#if times.filter((t: any) => t.ativo).length === 0}
+
+
+
+
Nenhum time cadastrado. Clique em "Novo Time" para começar.
+
+
+ {/if}
+
+
+
+ {#if mostrarModalMembros && timeParaMembros}
+
+ {/if}
+
+
+ {#if mostrarConfirmacaoExclusao && timeParaExcluir}
+
+ {/if}
+
+
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index bf25b80..d0a988e 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -18,9 +18,11 @@ import type * as betterAuth_auth from "../betterAuth/auth.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as 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";
@@ -29,6 +31,7 @@ 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";
@@ -37,6 +40,7 @@ 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";
@@ -66,9 +70,11 @@ declare const fullApi: ApiFromModules<{
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
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;
@@ -77,6 +83,7 @@ declare const fullApi: ApiFromModules<{
logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin;
menuPermissoes: typeof menuPermissoes;
+ migrarParaTimes: typeof migrarParaTimes;
migrarUsuariosAdmin: typeof migrarUsuariosAdmin;
monitoramento: typeof monitoramento;
perfisCustomizados: typeof perfisCustomizados;
@@ -85,6 +92,7 @@ declare const fullApi: ApiFromModules<{
simbolos: typeof simbolos;
solicitacoesAcesso: typeof solicitacoesAcesso;
templatesMensagens: typeof templatesMensagens;
+ times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
verificarMatriculas: typeof verificarMatriculas;
diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts
index 83775b7..f004460 100644
--- a/packages/backend/convex/crons.ts
+++ b/packages/backend/convex/crons.ts
@@ -17,5 +17,13 @@ crons.interval(
internal.chat.limparIndicadoresDigitacao
);
+// Atualizar status de férias dos funcionários diariamente
+crons.interval(
+ "atualizar-status-ferias",
+ { hours: 24 },
+ internal.ferias.atualizarStatusTodosFuncionarios,
+ {}
+);
+
export default crons;
diff --git a/packages/backend/convex/cursos.ts b/packages/backend/convex/cursos.ts
new file mode 100644
index 0000000..d284c49
--- /dev/null
+++ b/packages/backend/convex/cursos.ts
@@ -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;
+ },
+});
+
diff --git a/packages/backend/convex/ferias.ts b/packages/backend/convex/ferias.ts
new file mode 100644
index 0000000..06e423b
--- /dev/null
+++ b/packages/backend/convex/ferias.ts
@@ -0,0 +1,475 @@
+import { v } from "convex/values";
+import { mutation, query, internalMutation } from "./_generated/server";
+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
= [];
+
+ 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
+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");
+ }
+
+ if (args.periodos.length > 3) {
+ throw new Error("Máximo de 3 períodos permitidos");
+ }
+
+ const funcionario = await ctx.db.get(args.funcionarioId);
+ if (!funcionario) throw new Error("Funcionário não encontrado");
+
+ // 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",
+ },
+ ],
+ });
+
+ // 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}`,
+ },
+ ],
+ });
+
+ // 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");
+ }
+
+ if (args.novosPeriodos.length > 3) {
+ throw new Error("Máximo de 3 períodos permitidos");
+ }
+
+ const funcionario = await ctx.db.get(solicitacao.funcionarioId);
+
+ 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,
+ },
+ ],
+ });
+
+ // 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;
+ },
+});
+
diff --git a/packages/backend/convex/funcionarios.ts b/packages/backend/convex/funcionarios.ts
index 1d7c8e1..6c198f3 100644
--- a/packages/backend/convex/funcionarios.ts
+++ b/packages/backend/convex/funcionarios.ts
@@ -48,7 +48,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(),
@@ -149,13 +149,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);
@@ -168,7 +170,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(),
@@ -269,13 +271,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;
@@ -306,13 +310,52 @@ 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,
+ tipo: simbolo.tipo,
+ vencValor: simbolo.vencValor,
+ repValor: simbolo.repValor,
valor: simbolo.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;
+ },
+});
diff --git a/packages/backend/convex/migrarParaTimes.ts b/packages/backend/convex/migrarParaTimes.ts
new file mode 100644
index 0000000..df26043
--- /dev/null
+++ b/packages/backend/convex/migrarParaTimes.ts
@@ -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();
+
+ 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;
+ },
+});
+
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index b73c2da..7fa0a75 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -26,11 +26,16 @@ 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")
+ )),
// Dados Pessoais Adicionais (opcionais)
nomePai: v.optional(v.string()),
@@ -135,7 +140,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"),
@@ -145,11 +151,87 @@ 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"]),
+
+ 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(),
diff --git a/packages/backend/convex/times.ts b/packages/backend/convex/times.ts
new file mode 100644
index 0000000..5c97724
--- /dev/null
+++ b/packages/backend/convex/times.ts
@@ -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;
+ },
+});
+