From a93d55f02bbbe1b18a37db2a34d5a875bc046463 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 4 Nov 2025 14:23:46 -0300 Subject: [PATCH] feat: implement absence management features in the dashboard - Added functionality for managing absence requests, including listing, approving, and rejecting requests. - Enhanced the user interface to display statistics and pending requests for better oversight. - Updated backend schema to support absence requests and notifications, ensuring data integrity and efficient handling. - Integrated new components for absence request forms and approval workflows, improving user experience and administrative efficiency. --- apps/web/src/app.css | 2 + .../lib/components/AprovarAusencias.svelte | 398 ++++++++ .../ausencias/CalendarioAusencias.svelte | 487 ++++++++++ .../WizardSolicitacaoAusencia.svelte | 437 +++++++++ .../components/chat/NotificationBell.svelte | 899 ++++++++++-------- .../(dashboard)/alterar-senha/+page.svelte | 2 +- .../(dashboard)/gestao-pessoas/+page.svelte | 266 +++++- .../routes/(dashboard)/perfil/+page.svelte | 458 ++++++++- .../recursos-humanos/ausencias/+page.svelte | 419 ++++++++ .../secretaria-executiva/+page.svelte | 262 ++++- packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/ausencias.ts | 666 +++++++++++++ packages/backend/convex/schema.ts | 36 + 13 files changed, 3837 insertions(+), 497 deletions(-) create mode 100644 apps/web/src/lib/components/AprovarAusencias.svelte create mode 100644 apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte create mode 100644 apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte create mode 100644 packages/backend/convex/ausencias.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 7d3fb0f..a436e88 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1,6 +1,8 @@ @import "tailwindcss"; @plugin "daisyui"; +/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */ + /* Estilo padrão dos botões - mesmo estilo do sidebar */ .btn-standard { @apply font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors; diff --git a/apps/web/src/lib/components/AprovarAusencias.svelte b/apps/web/src/lib/components/AprovarAusencias.svelte new file mode 100644 index 0000000..68f5b53 --- /dev/null +++ b/apps/web/src/lib/components/AprovarAusencias.svelte @@ -0,0 +1,398 @@ + + +
+ +
+

Aprovar/Reprovar Ausência

+

Analise a solicitação e tome uma decisão

+
+ + +
+
+ +
+

+ + + + Funcionário +

+
+
+

Nome

+

{solicitacao.funcionario?.nome || "N/A"}

+
+ {#if solicitacao.time} +
+

Time

+
+ {solicitacao.time.nome} +
+
+ {/if} +
+
+ +
+ + +
+

+ + + + Período da Ausência +

+
+
+
Data Início
+
+ {new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} +
+
+
+
Data Fim
+
+ {new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} +
+
+
+
Total de Dias
+
+ {totalDias} +
+
dias corridos
+
+
+
+ +
+ + +
+

+ + + + Motivo da Ausência +

+
+
+

{solicitacao.motivo}

+
+
+
+ + +
+
+ Status: +
+ {getStatusTexto(solicitacao.status)} +
+
+
+ + + {#if erro} +
+ + + + {erro} +
+ {/if} + + + {#if solicitacao.status === "aguardando_aprovacao"} +
+ + +
+ + + {#if motivoReprovacao !== undefined} +
+
+ + +
+
+ {/if} + {:else} +
+ + + + Esta solicitação já foi processada. +
+ {/if} + + +
+ +
+
+
+
+ + + + + + diff --git a/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte b/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte new file mode 100644 index 0000000..94ae63b --- /dev/null +++ b/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte @@ -0,0 +1,487 @@ + + +
+ + {#if !readonly} +
+ + + +
+

Como usar:

+
    +
  • Clique e arraste no calendário para selecionar o período de ausência
  • +
  • Você pode visualizar suas ausências já solicitadas no calendário
  • +
  • A data de início não pode ser no passado
  • +
+
+
+ {/if} + + +
+ + + {#if ausenciasExistentes.length > 0 || readonly} +
+
+
+ Aguardando Aprovação +
+
+
+ Aprovado +
+
+
+ Reprovado +
+
+ {/if} + + + {#if dataInicio && dataFim && !readonly} +
+
+

+ + + + Período Selecionado +

+
+
+

Data Início

+

{new Date(dataInicio).toLocaleDateString("pt-BR")}

+
+
+

Data Fim

+

{new Date(dataFim).toLocaleDateString("pt-BR")}

+
+
+

Total de Dias

+

{calcularDias(dataInicio, dataFim)} dias

+
+
+
+
+ {/if} +
+ + + diff --git a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte new file mode 100644 index 0000000..e02373f --- /dev/null +++ b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte @@ -0,0 +1,437 @@ + + +
+ +
+

Nova Solicitação de Ausência

+

Solicite uma ausência para assuntos particulares

+
+ + +
+
+
+
+ {#if passoAtual > 1} + + + + {:else} + {passoAtual} + {/if} +
+
+
Selecionar Período
+
Escolha as datas no calendário
+
+
+
+
+
+
+ {#if passoAtual > 2} + + + + {:else} + 2 + {/if} +
+
+
Informar Motivo
+
Descreva o motivo da ausência
+
+
+
+
+ + +
+
+ {#if passoAtual === 1} + +
+
+

Selecione o Período

+

+ Clique e arraste no calendário para selecionar o período de ausência +

+
+ + + + {#if dataInicio && dataFim} +
+ + + +
+

Período selecionado!

+

+ De {new Date(dataInicio).toLocaleDateString("pt-BR")} até{" "} + {new Date(dataFim).toLocaleDateString("pt-BR")} ({totalDias} dias) +

+
+
+ {/if} +
+ {:else if passoAtual === 2} + +
+
+

Informe o Motivo

+

+ Descreva o motivo da sua solicitação de ausência (mínimo 10 caracteres) +

+
+ + + {#if dataInicio && dataFim} +
+
+

+ + + + Resumo do Período +

+
+
+

Data Início

+

{new Date(dataInicio).toLocaleDateString("pt-BR")}

+
+
+

Data Fim

+

{new Date(dataFim).toLocaleDateString("pt-BR")}

+
+
+

Total de Dias

+

+ {totalDias} dias +

+
+
+
+
+ {/if} + + +
+ + + +
+ + {#if motivo.trim().length > 0 && motivo.trim().length < 10} +
+ + + + O motivo deve ter no mínimo 10 caracteres +
+ {/if} +
+ {/if} + + +
+ + + {#if passoAtual < totalPassos} + + {:else} + + {/if} +
+ + +
+ +
+
+
+
+ + + diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte index b7056b4..ec3a437 100644 --- a/apps/web/src/lib/components/chat/NotificationBell.svelte +++ b/apps/web/src/lib/components/chat/NotificationBell.svelte @@ -1,398 +1,501 @@ - - - - - + + + + + diff --git a/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte b/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte index 8de5ca7..c5f3361 100644 --- a/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte +++ b/apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte @@ -94,7 +94,7 @@ const resultado = await convex.mutation(api.autenticacao.alterarSenha, { token: authStore.token, - senhaAntiga: senhaAtual, + senhaAtual: senhaAtual, novaSenha: novaSenha, }); diff --git a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte index ca2f13d..1325a4e 100644 --- a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte @@ -1,48 +1,218 @@ - - -
- - -
-
-
- - - -
-
-

Secretaria de Gestão de Pessoas

-

Gestão estratégica de pessoas

-
-
-
- -
-
-
-
- - - -
-

Módulo em Desenvolvimento

-

- O módulo da Secretaria de Gestão de Pessoas está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão estratégica de pessoas. -

-
- - - - Em Desenvolvimento -
-
-
-
-
- + + +
+ + +
+
+
+ + + +
+
+

+ Secretaria de Gestão de Pessoas +

+

Gestão estratégica de pessoas

+
+
+
+ + +
+
+
+

+ + + + Gestão de Ausências +

+ +
+ + +
+
+
Total
+
{stats.total}
+
Solicitações
+
+
+
Pendentes
+
{stats.pendentes}
+
Aguardando
+
+
+
Aprovadas
+
{stats.aprovadas}
+
Deferidas
+
+
+
Reprovadas
+
{stats.reprovadas}
+
Indeferidas
+
+
+ + +
+

+ Solicitações Pendentes de Aprovação +

+ {#if pendentes.length === 0} +
+ + + + Nenhuma solicitação pendente no momento. +
+ {:else} +
+ + + + + + + + + + + + {#each pendentes as ausencia} + + + + + + + + {/each} + +
FuncionárioPeríodoDiasStatusSolicitado em
+ {ausencia.funcionario?.nome || "N/A"} + + {new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "} + {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")} + + {calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias + +
+ {getStatusTexto(ausencia.status)} +
+
+ {new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")} +
+
+ {#if stats.pendentes > 5} +
+ +
+ {/if} + {/if} +
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/perfil/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/+page.svelte index d9d5d09..8fbf9eb 100644 --- a/apps/web/src/routes/(dashboard)/perfil/+page.svelte +++ b/apps/web/src/routes/(dashboard)/perfil/+page.svelte @@ -4,13 +4,15 @@ import { authStore } from "$lib/stores/auth.svelte"; import AprovarFerias from "$lib/components/AprovarFerias.svelte"; import WizardSolicitacaoFerias from "$lib/components/ferias/WizardSolicitacaoFerias.svelte"; + import WizardSolicitacaoAusencia from "$lib/components/ausencias/WizardSolicitacaoAusencia.svelte"; + import AprovarAusencias from "$lib/components/AprovarAusencias.svelte"; import { generateAvatarGallery, type Avatar } from "$lib/utils/avatars"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import { page } from "$app/stores"; const client = useConvexClient(); - let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "aprovar-ferias">( + let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "minhas-ausencias" | "aprovar-ferias" | "aprovar-ausencias">( "meu-perfil" ); let solicitacaoSelecionada = $state(null); @@ -29,6 +31,14 @@ let mostrarWizard = $state(false); let filtroStatusFerias = $state("todos"); + // Estados para Minhas Ausências + let mostrarWizardAusencia = $state(false); + let filtroStatusAusencias = $state("todos"); + let solicitacaoAusenciaSelecionada = $state | null>(null); + + // Estados para Aprovar Ausências (Gestores) + let solicitacaoAusenciaAprovar = $state | null>(null); + // Galeria de avatares (30 avatares profissionais 3D realistas) const avatarGallery = generateAvatarGallery(30); @@ -42,10 +52,15 @@ } }); + // FuncionarioId disponível diretamente do authStore + const funcionarioIdDisponivel = $derived(authStore.usuario?.funcionarioId ?? null); + // Debug: Verificar funcionarioId $effect(() => { console.log("🔍 [Perfil] funcionarioId:", authStore.usuario?.funcionarioId); console.log("🔍 [Perfil] Usuário completo:", authStore.usuario); + console.log("🔍 [Perfil] funcionarioIdDisponivel:", funcionarioIdDisponivel); + console.log("🔍 [Perfil] Botão habilitado?", !!funcionarioIdDisponivel); }); // Queries @@ -65,6 +80,14 @@ : { data: [] } ); + const ausenciasSubordinadosQuery = $derived( + authStore.usuario?._id + ? useQuery(api.ausencias.listarSolicitacoesSubordinados, { + gestorId: authStore.usuario._id as Id<"usuarios">, + }) + : { data: [] } + ); + const minhasSolicitacoesQuery = $derived( funcionarioQuery.data ? useQuery(api.ferias.listarMinhasSolicitacoes, { @@ -73,6 +96,14 @@ : { data: [] } ); + const minhasAusenciasQuery = $derived( + funcionarioQuery.data + ? useQuery(api.ausencias.listarMinhasSolicitacoes, { + funcionarioId: funcionarioQuery.data._id, + }) + : { data: [] } + ); + const meuTimeQuery = $derived( funcionarioQuery.data ? useQuery(api.times.obterTimeFuncionario, { @@ -93,7 +124,11 @@ const solicitacoesSubordinados = $derived( solicitacoesSubordinadosQuery?.data || [] ); + const ausenciasSubordinados = $derived( + ausenciasSubordinadosQuery?.data || [] + ); const minhasSolicitacoes = $derived(minhasSolicitacoesQuery?.data || []); + const minhasAusencias = $derived(minhasAusenciasQuery?.data || []); const meuTime = $derived(meuTimeQuery?.data); const meusTimesGestor = $derived(meusTimesGestorQuery?.data || []); @@ -108,6 +143,14 @@ }) ); + // Filtrar minhas ausências + const ausenciasFiltradas = $derived( + minhasAusencias.filter((a) => { + if (filtroStatusAusencias !== "todos" && a.status !== filtroStatusAusencias) return false; + return true; + }) + ); + // Estatísticas das minhas férias const statsMinhasFerias = $derived({ total: minhasSolicitacoes.length, @@ -117,6 +160,14 @@ emFerias: funcionario?.statusFerias === "em_ferias" ? 1 : 0, }); + // Estatísticas das minhas ausências + const statsMinhasAusencias = $derived({ + total: minhasAusencias.length, + aguardando: minhasAusencias.filter((a) => a.status === "aguardando_aprovacao").length, + aprovadas: minhasAusencias.filter((a) => a.status === "aprovado").length, + reprovadas: minhasAusencias.filter((a) => a.status === "reprovado").length, + }); + async function recarregar() { solicitacaoSelecionada = null; } @@ -512,6 +563,29 @@ Minhas Férias + + {#if ehGestor} + + {/if} @@ -1221,6 +1325,151 @@ + {:else if abaAtiva === "minhas-ausencias"} + +
+ +
+
+
+ + + +
+
Total
+
{statsMinhasAusencias.total}
+
Solicitações
+
+ +
+
+ + + +
+
Aguardando
+
{statsMinhasAusencias.aguardando}
+
Pendentes
+
+ +
+
+ + + +
+
Aprovadas
+
{statsMinhasAusencias.aprovadas}
+
Deferidas
+
+ +
+
+ + + +
+
Reprovadas
+
{statsMinhasAusencias.reprovadas}
+
Indeferidas
+
+
+ + +
+
+
+

Filtros

+ +
+
+
+ + +
+
+
+
+ + +
+
+

+ Minhas Solicitações ({ausenciasFiltradas.length}) +

+ + {#if ausenciasFiltradas.length === 0} +
+ + + + Nenhuma solicitação encontrada com os filtros aplicados. +
+ {:else} +
+ + + + + + + + + + + + {#each ausenciasFiltradas as ausencia} + + + + + + + + {/each} + +
PeríodoDiasMotivoStatusSolicitado em
+ {new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")} + + {Math.ceil((new Date(ausencia.dataFim).getTime() - new Date(ausencia.dataInicio).getTime()) / (1000 * 60 * 60 * 24)) + 1} dias + + {ausencia.motivo} + +
+ {getStatusTexto(ausencia.status)} +
+
{new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")}
+
+ {/if} +
+
+
{:else if abaAtiva === "aprovar-ferias"}
@@ -1379,10 +1628,162 @@ {/if}
+ {:else if abaAtiva === "aprovar-ausencias"} + +
+
+

+ + + + Solicitações de Ausências da Equipe +
+ {ausenciasSubordinados.length} +
+

+ + {#if ausenciasSubordinados.length === 0} +
+ + + + Nenhuma solicitação pendente no momento. +
+ {:else} +
+ + + + + + + + + + + + + {#each ausenciasSubordinados as ausencia} + + + + + + + + + {/each} + +
FuncionárioTimePeríodoDiasStatusAções
+
+ {ausencia.funcionario?.nome || "N/A"} +
+
+ {#if ausencia.time} +
+ {ausencia.time.nome} +
+ {/if} +
+ {new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "} + {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")} + + {Math.ceil((new Date(ausencia.dataFim).getTime() - new Date(ausencia.dataInicio).getTime()) / (1000 * 60 * 60 * 24)) + 1} dias + +
+ {getStatusTexto(ausencia.status)} +
+
+ {#if ausencia.status === "aguardando_aprovacao"} + + {:else} + + {/if} +
+
+ {/if} +
+
{/if} - + {#if solicitacaoSelecionada} {/if} + + +{#if mostrarWizardAusencia && funcionarioIdDisponivel} + + + + + + +{/if} + + +{#if solicitacaoAusenciaAprovar && authStore.usuario} + {#await client.query(api.ausencias.obterDetalhes, { + solicitacaoId: solicitacaoAusenciaAprovar, + }) then detalhes} + {#if detalhes} + + + + + {/if} + {/await} +{/if} diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte new file mode 100644 index 0000000..8341aae --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ausencias/+page.svelte @@ -0,0 +1,419 @@ + + +
+ + + + +
+
+
+
+ + + +
+
+

Dashboard de Ausências

+

+ Visão geral de todas as solicitações de ausências +

+
+
+ +
+
+ + +
+
+
+ + + +
+
Total
+
{stats.total}
+
Solicitações
+
+ +
+
+ + + +
+
Pendentes
+
{stats.aguardando}
+
Aguardando
+
+ +
+
+ + + +
+
Aprovadas
+
{stats.aprovadas}
+
Deferidas
+
+ +
+
+ + + +
+
Reprovadas
+
{stats.reprovadas}
+
Indeferidas
+
+
+ + +
+
+

Filtros

+
+
+ + +
+
+
+
+ + +
+
+

+ Todas as Solicitações ({ausenciasFiltradas.length}) +

+ + {#if ausenciasFiltradas.length === 0} +
+ + + + Nenhuma solicitação encontrada com os filtros aplicados. +
+ {:else} +
+ + + + + + + + + + + + + + + {#each ausenciasFiltradas as ausencia} + + + + + + + + + + + {/each} + +
FuncionárioTimePeríodoDiasMotivoStatusSolicitado emAções
+ {ausencia.funcionario?.nome || "N/A"} + + {#if ausencia.time} +
+ {ausencia.time.nome} +
+ {:else} + Sem time + {/if} +
+ {new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "} + {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")} + + {calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias + + {ausencia.motivo} + +
+ {getStatusTexto(ausencia.status)} +
+
+ {new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")} + + {#if ausencia.status === "aguardando_aprovacao"} + + {:else} + + {/if} +
+
+ {/if} +
+
+
+ + +{#if solicitacaoSelecionada && authStore.usuario} + {#await client.query(api.ausencias.obterDetalhes, { + solicitacaoId: solicitacaoSelecionada, + }) then detalhes} + {#if detalhes} + + + + + {/if} + {/await} +{/if} + diff --git a/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte b/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte index 6335814..e92c758 100644 --- a/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte +++ b/apps/web/src/routes/(dashboard)/secretaria-executiva/+page.svelte @@ -1,48 +1,214 @@ - - -
- - -
-
-
- - - -
-
-

Secretaria Executiva

-

Gestão executiva e administrativa

-
-
-
- -
-
-
-
- - - -
-

Módulo em Desenvolvimento

-

- O módulo da Secretaria Executiva está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão executiva e administrativa. -

-
- - - - Em Desenvolvimento -
-
-
-
-
- + + +
+ + +
+
+
+ + + +
+
+

Secretaria Executiva

+

Gestão executiva e administrativa

+
+
+
+ + +
+
+
+

+ + + + Gestão de Ausências +

+ +
+ + +
+
+
Total
+
{stats.total}
+
Solicitações
+
+
+
Pendentes
+
{stats.pendentes}
+
Aguardando
+
+
+
Aprovadas
+
{stats.aprovadas}
+
Deferidas
+
+
+
Reprovadas
+
{stats.reprovadas}
+
Indeferidas
+
+
+ + +
+

Solicitações Pendentes de Aprovação

+ {#if pendentes.length === 0} +
+ + + + Nenhuma solicitação pendente no momento. +
+ {:else} +
+ + + + + + + + + + + + {#each pendentes as ausencia} + + + + + + + + {/each} + +
FuncionárioPeríodoDiasStatusSolicitado em
+ {ausencia.funcionario?.nome || "N/A"} + + {new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "} + {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")} + + {calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias + +
+ {getStatusTexto(ausencia.status)} +
+
+ {new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")} +
+
+ {#if stats.pendentes > 5} +
+ +
+ {/if} + {/if} +
+
+
+
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index a0b2e5f..e33dc3d 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -11,6 +11,7 @@ import type * as actions_email from "../actions/email.js"; import type * as actions_smtp from "../actions/smtp.js"; import type * as atestadosLicencas from "../atestadosLicencas.js"; +import type * as ausencias from "../ausencias.js"; import type * as autenticacao from "../autenticacao.js"; import type * as auth_utils from "../auth/utils.js"; import type * as chat from "../chat.js"; @@ -60,6 +61,7 @@ declare const fullApi: ApiFromModules<{ "actions/email": typeof actions_email; "actions/smtp": typeof actions_smtp; atestadosLicencas: typeof atestadosLicencas; + ausencias: typeof ausencias; autenticacao: typeof autenticacao; "auth/utils": typeof auth_utils; chat: typeof chat; diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts new file mode 100644 index 0000000..93306bd --- /dev/null +++ b/packages/backend/convex/ausencias.ts @@ -0,0 +1,666 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; +import { internal, api } from "./_generated/api"; +import { Id, Doc } from "./_generated/dataModel"; + +// Query: Listar todas as solicitações (para RH) +export const listarTodas = query({ + args: {}, + handler: async (ctx) => { + const solicitacoes = await ctx.db.query("solicitacoesAusencias").collect(); + + const solicitacoesComDetalhes = await Promise.all( + solicitacoes.map(async (s) => { + const funcionario = await ctx.db.get(s.funcionarioId); + + // Buscar time do funcionário + const membroTime = await ctx.db + .query("timesMembros") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", s.funcionarioId) + ) + .filter((q) => q.eq(q.field("ativo"), true)) + .first(); + + let time = null; + if (membroTime) { + time = await ctx.db.get(membroTime.timeId); + } + + return { + ...s, + funcionario, + time, + }; + }) + ); + + return solicitacoesComDetalhes.sort( + (a, b) => b.criadoEm - a.criadoEm + ); + }, +}); + +// Query: Listar solicitações do funcionário +export const listarMinhasSolicitacoes = query({ + args: { funcionarioId: v.id("funcionarios") }, + handler: async (ctx, args) => { + const solicitacoes = await ctx.db + .query("solicitacoesAusencias") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) + .order("desc") + .collect(); + + // Enriquecer com dados do funcionário e time + const solicitacoesComDetalhes = await Promise.all( + solicitacoes.map(async (s) => { + const funcionario = await ctx.db.get(s.funcionarioId); + + // Buscar time do funcionário + const membroTime = await ctx.db + .query("timesMembros") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", s.funcionarioId) + ) + .filter((q) => q.eq(q.field("ativo"), true)) + .first(); + + let time = null; + if (membroTime) { + time = await ctx.db.get(membroTime.timeId); + } + + return { + ...s, + funcionario, + time, + }; + }) + ); + + return solicitacoesComDetalhes; + }, +}); + +// Query: Listar solicitações dos subordinados (para gestores) +export const listarSolicitacoesSubordinados = query({ + args: { gestorId: v.id("usuarios") }, + handler: async (ctx, args) => { + // Buscar times onde o usuário é gestor + const timesGestor = await ctx.db + .query("times") + .withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId)) + .filter((q) => q.eq(q.field("ativo"), true)) + .collect(); + + const solicitacoes: Array & { + funcionario: Doc<"funcionarios"> | null; + time: Doc<"times"> | null; + }> = []; + + for (const time of timesGestor) { + // Buscar membros do time + const membros = await ctx.db + .query("timesMembros") + .withIndex("by_time_and_ativo", (q) => + q.eq("timeId", time._id).eq("ativo", true) + ) + .collect(); + + // Buscar solicitações de cada membro + for (const membro of membros) { + const solic = await ctx.db + .query("solicitacoesAusencias") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", membro.funcionarioId) + ) + .collect(); + + // Adicionar info do funcionário + for (const s of solic) { + const funcionario = await ctx.db.get(s.funcionarioId); + solicitacoes.push({ + ...s, + funcionario, + time, + }); + } + } + } + + return solicitacoes.sort((a, b) => b.criadoEm - a.criadoEm); + }, +}); + +// Query: Obter detalhes completos de uma solicitação +export const obterDetalhes = query({ + args: { solicitacaoId: v.id("solicitacoesAusencias") }, + handler: async (ctx, args) => { + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) return null; + + const funcionario = await ctx.db.get(solicitacao.funcionarioId); + let gestor = null; + if (solicitacao.gestorId) { + gestor = await ctx.db.get(solicitacao.gestorId); + } + + // Buscar time do funcionário + const membroTime = await ctx.db + .query("timesMembros") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", solicitacao.funcionarioId) + ) + .filter((q) => q.eq(q.field("ativo"), true)) + .first(); + + let time = null; + if (membroTime) { + time = await ctx.db.get(membroTime.timeId); + } + + return { + ...solicitacao, + funcionario, + gestor, + time, + }; + }, +}); + +// Query: Obter notificações não lidas +export const obterNotificacoesNaoLidas = query({ + args: { usuarioId: v.id("usuarios") }, + handler: async (ctx, args) => { + const notificacoes = await ctx.db + .query("notificacoesAusencias") + .withIndex("by_destinatario_and_lida", (q) => + q.eq("destinatarioId", args.usuarioId).eq("lida", false) + ) + .order("desc") + .collect(); + + return notificacoes; + }, +}); + +// Query: Contar solicitações pendentes para gestor +export const contarPendentesGestor = query({ + args: { gestorId: v.id("usuarios") }, + handler: async (ctx, args) => { + // Buscar times onde o usuário é gestor + const timesGestor = await ctx.db + .query("times") + .withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId)) + .filter((q) => q.eq(q.field("ativo"), true)) + .collect(); + + let totalPendentes = 0; + + for (const time of timesGestor) { + // Buscar membros do time + const membros = await ctx.db + .query("timesMembros") + .withIndex("by_time_and_ativo", (q) => + q.eq("timeId", time._id).eq("ativo", true) + ) + .collect(); + + // Contar solicitações pendentes de cada membro + for (const membro of membros) { + const pendentes = await ctx.db + .query("solicitacoesAusencias") + .withIndex("by_funcionario_and_status", (q) => + q + .eq("funcionarioId", membro.funcionarioId) + .eq("status", "aguardando_aprovacao") + ) + .collect(); + totalPendentes += pendentes.length; + } + } + + return totalPendentes; + }, +}); + +// Helper: Verificar se há sobreposição de datas +function verificarSobreposicao( + inicio1: string, + fim1: string, + inicio2: string, + fim2: string +): boolean { + const d1Inicio = new Date(inicio1); + const d1Fim = new Date(fim1); + const d2Inicio = new Date(inicio2); + const d2Fim = new Date(fim2); + + return d1Inicio <= d2Fim && d2Inicio <= d1Fim; +} + +// Helper: Encontrar gestor do funcionário +async function encontrarGestorDoFuncionario( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<"funcionarios"> +): Promise | null> { + const membroTime = await ctx.db + .query("timesMembros") + .withIndex("by_funcionario", (q) => q.eq("funcionarioId", funcionarioId)) + .filter((q) => q.eq(q.field("ativo"), true)) + .first(); + + if (!membroTime) return null; + + const time = await ctx.db.get(membroTime.timeId); + if (!time) return null; + + return time.gestorId; +} + +// Mutation: Criar solicitação de ausência +export const criarSolicitacao = mutation({ + args: { + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + motivo: v.string(), + }, + returns: v.id("solicitacoesAusencias"), + handler: async (ctx, args) => { + // Validações + if (args.motivo.trim().length < 10) { + throw new Error("O motivo deve ter no mínimo 10 caracteres"); + } + + const dataInicio = new Date(args.dataInicio); + const dataFim = new Date(args.dataFim); + const hoje = new Date(); + hoje.setHours(0, 0, 0, 0); + + if (dataInicio < hoje) { + throw new Error("A data de início não pode ser no passado"); + } + + if (dataFim < dataInicio) { + throw new Error("A data de fim deve ser maior ou igual à data de início"); + } + + const funcionario = await ctx.db.get(args.funcionarioId); + if (!funcionario) { + throw new Error("Funcionário não encontrado"); + } + + // Verificar sobreposição com outras solicitações aprovadas ou pendentes + const solicitacoesExistentes = await ctx.db + .query("solicitacoesAusencias") + .withIndex("by_funcionario", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) + .collect(); + + for (const solic of solicitacoesExistentes) { + if ( + solic.status === "aprovado" || + solic.status === "aguardando_aprovacao" + ) { + if ( + verificarSobreposicao( + args.dataInicio, + args.dataFim, + solic.dataInicio, + solic.dataFim + ) + ) { + throw new Error( + "Já existe uma solicitação aprovada ou pendente para este período" + ); + } + } + } + + // Criar solicitação + const solicitacaoId = await ctx.db.insert("solicitacoesAusencias", { + funcionarioId: args.funcionarioId, + dataInicio: args.dataInicio, + dataFim: args.dataFim, + motivo: args.motivo.trim(), + status: "aguardando_aprovacao", + criadoEm: Date.now(), + }); + + // Encontrar gestor do funcionário + const gestorId = await encontrarGestorDoFuncionario( + ctx, + args.funcionarioId + ); + + if (gestorId) { + // Criar notificação in-app para gestor + await ctx.db.insert("notificacoesAusencias", { + destinatarioId: gestorId, + solicitacaoAusenciaId: solicitacaoId, + tipo: "nova_solicitacao", + lida: false, + mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}`, + }); + + // Buscar usuário do gestor para enviar email e chat + const gestorUsuario = await ctx.db.get(gestorId); + const funcionarioUsuario = await ctx.db + .query("usuarios") + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", args.funcionarioId) + ) + .first(); + + if (gestorUsuario && funcionarioUsuario) { + // Enviar email ao gestor + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: gestorUsuario.email, + destinatarioId: gestorId, + assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`, + corpo: `

Olá ${gestorUsuario.nome},

+

O funcionário ${funcionario.nome} solicitou uma ausência:

+
    +
  • Período: ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}
  • +
  • Motivo: ${args.motivo}
  • +
+

Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.

`, + enviadoPorId: funcionarioUsuario._id, + }); + + // Criar ou obter conversa entre gestor e funcionário + const conversasExistentes = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + let conversaId: Id<"conversas"> | null = null; + for (const conversa of conversasExistentes) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.includes(gestorId) && + conversa.participantes.includes(funcionarioUsuario._id) + ) { + conversaId = conversa._id; + break; + } + } + + if (!conversaId) { + conversaId = await ctx.db.insert("conversas", { + tipo: "individual", + participantes: [gestorId, funcionarioUsuario._id], + criadoPor: funcionarioUsuario._id, + criadoEm: Date.now(), + }); + } + + // Criar mensagem de chat + await ctx.db.insert("mensagens", { + conversaId, + remetenteId: funcionarioUsuario._id, + tipo: "texto", + conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}. Motivo: ${args.motivo}`, + enviadaEm: Date.now(), + }); + } + } + + return solicitacaoId; + }, +}); + +// Mutation: Aprovar ausência +export const aprovar = mutation({ + args: { + solicitacaoId: v.id("solicitacoesAusencias"), + gestorId: v.id("usuarios"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) { + throw new Error("Solicitação não encontrada"); + } + + if (solicitacao.status !== "aguardando_aprovacao") { + throw new Error("Esta solicitação já foi processada"); + } + + // Verificar se o gestor tem permissão (é gestor do time do funcionário) + const gestorIdDoFuncionario = await encontrarGestorDoFuncionario( + ctx, + solicitacao.funcionarioId + ); + + if (gestorIdDoFuncionario !== args.gestorId) { + throw new Error("Você não tem permissão para aprovar esta solicitação"); + } + + const funcionario = await ctx.db.get(solicitacao.funcionarioId); + if (!funcionario) { + throw new Error("Funcionário não encontrado"); + } + + // Atualizar solicitação + await ctx.db.patch(args.solicitacaoId, { + status: "aprovado", + gestorId: args.gestorId, + dataAprovacao: Date.now(), + }); + + // Buscar usuário do funcionário + const funcionarioUsuario = await ctx.db + .query("usuarios") + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", solicitacao.funcionarioId) + ) + .first(); + + if (funcionarioUsuario) { + // Criar notificação in-app para funcionário + await ctx.db.insert("notificacoesAusencias", { + destinatarioId: funcionarioUsuario._id, + solicitacaoAusenciaId: args.solicitacaoId, + tipo: "aprovado", + lida: false, + mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} foi aprovada!`, + }); + + const gestorUsuario = await ctx.db.get(args.gestorId); + + if (gestorUsuario) { + // Enviar email ao funcionário + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + assunto: "Solicitação de Ausência Aprovada", + corpo: `

Olá ${funcionarioUsuario.nome},

+

Sua solicitação de ausência foi aprovada pelo gestor ${gestorUsuario.nome}:

+
    +
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • +
  • Motivo: ${solicitacao.motivo}
  • +
`, + enviadoPorId: args.gestorId, + }); + + // Criar ou obter conversa + const conversasExistentes = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + let conversaId: Id<"conversas"> | null = null; + for (const conversa of conversasExistentes) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.includes(args.gestorId) && + conversa.participantes.includes(funcionarioUsuario._id) + ) { + conversaId = conversa._id; + break; + } + } + + if (!conversaId) { + conversaId = await ctx.db.insert("conversas", { + tipo: "individual", + participantes: [args.gestorId, funcionarioUsuario._id], + criadoPor: args.gestorId, + criadoEm: Date.now(), + }); + } + + // Criar mensagem de chat + await ctx.db.insert("mensagens", { + conversaId, + remetenteId: args.gestorId, + tipo: "texto", + conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}.`, + enviadaEm: Date.now(), + }); + } + } + + return null; + }, +}); + +// Mutation: Reprovar ausência +export const reprovar = mutation({ + args: { + solicitacaoId: v.id("solicitacoesAusencias"), + gestorId: v.id("usuarios"), + motivoReprovacao: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) { + throw new Error("Solicitação não encontrada"); + } + + if (solicitacao.status !== "aguardando_aprovacao") { + throw new Error("Esta solicitação já foi processada"); + } + + // Verificar se o gestor tem permissão + const gestorIdDoFuncionario = await encontrarGestorDoFuncionario( + ctx, + solicitacao.funcionarioId + ); + + if (gestorIdDoFuncionario !== args.gestorId) { + throw new Error("Você não tem permissão para reprovar esta solicitação"); + } + + const funcionario = await ctx.db.get(solicitacao.funcionarioId); + if (!funcionario) { + throw new Error("Funcionário não encontrado"); + } + + // Atualizar solicitação + await ctx.db.patch(args.solicitacaoId, { + status: "reprovado", + gestorId: args.gestorId, + dataReprovacao: Date.now(), + motivoReprovacao: args.motivoReprovacao.trim(), + }); + + // Buscar usuário do funcionário + const funcionarioUsuario = await ctx.db + .query("usuarios") + .withIndex("by_funcionarioId", (q) => + q.eq("funcionarioId", solicitacao.funcionarioId) + ) + .first(); + + if (funcionarioUsuario) { + // Criar notificação in-app para funcionário + await ctx.db.insert("notificacoesAusencias", { + destinatarioId: funcionarioUsuario._id, + solicitacaoAusenciaId: args.solicitacaoId, + tipo: "reprovado", + lida: false, + mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} foi reprovada. Motivo: ${args.motivoReprovacao}`, + }); + + const gestorUsuario = await ctx.db.get(args.gestorId); + + if (gestorUsuario) { + // Enviar email ao funcionário + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: funcionarioUsuario.email, + destinatarioId: funcionarioUsuario._id, + assunto: "Solicitação de Ausência Reprovada", + corpo: `

Olá ${funcionarioUsuario.nome},

+

Sua solicitação de ausência foi reprovada pelo gestor ${gestorUsuario.nome}:

+
    +
  • Período: ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
  • +
  • Motivo: ${solicitacao.motivo}
  • +
  • Motivo da Reprovação: ${args.motivoReprovacao}
  • +
`, + enviadoPorId: args.gestorId, + }); + + // Criar ou obter conversa + const conversasExistentes = await ctx.db + .query("conversas") + .filter((q) => q.eq(q.field("tipo"), "individual")) + .collect(); + + let conversaId: Id<"conversas"> | null = null; + for (const conversa of conversasExistentes) { + if ( + conversa.participantes.length === 2 && + conversa.participantes.includes(args.gestorId) && + conversa.participantes.includes(funcionarioUsuario._id) + ) { + conversaId = conversa._id; + break; + } + } + + if (!conversaId) { + conversaId = await ctx.db.insert("conversas", { + tipo: "individual", + participantes: [args.gestorId, funcionarioUsuario._id], + criadoPor: args.gestorId, + criadoEm: Date.now(), + }); + } + + // Criar mensagem de chat + await ctx.db.insert("mensagens", { + conversaId, + remetenteId: args.gestorId, + tipo: "texto", + conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}. Motivo: ${args.motivoReprovacao}`, + enviadaEm: Date.now(), + }); + } + } + + return null; + }, +}); + +// Mutation: Marcar notificação como lida +export const marcarComoLida = mutation({ + args: { + notificacaoId: v.id("notificacoesAusencias"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.notificacaoId, { + lida: true, + }); + return null; + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 0f07dbc..0662bab 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -266,6 +266,42 @@ export default defineSchema({ .index("by_destinatario", ["destinatarioId"]) .index("by_destinatario_and_lida", ["destinatarioId", "lida"]), + // Solicitações de Ausências + solicitacoesAusencias: defineTable({ + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + motivo: v.string(), + status: v.union( + v.literal("aguardando_aprovacao"), + v.literal("aprovado"), + v.literal("reprovado") + ), + gestorId: v.optional(v.id("usuarios")), + dataAprovacao: v.optional(v.number()), + dataReprovacao: v.optional(v.number()), + motivoReprovacao: v.optional(v.string()), + observacao: v.optional(v.string()), + criadoEm: v.number(), + }) + .index("by_funcionario", ["funcionarioId"]) + .index("by_status", ["status"]) + .index("by_funcionario_and_status", ["funcionarioId", "status"]), + + notificacoesAusencias: defineTable({ + destinatarioId: v.id("usuarios"), + solicitacaoAusenciaId: v.id("solicitacoesAusencias"), + tipo: v.union( + v.literal("nova_solicitacao"), + v.literal("aprovado"), + v.literal("reprovado") + ), + 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"),