From 16bcd2ac258ddf1a4e7ab46e602b6628bc8bc157 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Wed, 29 Oct 2025 22:05:29 -0300 Subject: [PATCH] feat: implement vacation management system with request approval, notification handling, and employee training tracking; enhance UI components for improved user experience --- .../src/lib/components/AprovarFerias.svelte | 378 ++++++++ .../src/lib/components/SolicitarFerias.svelte | 304 ++++++ .../components/chat/NotificationBell.svelte | 99 +- apps/web/src/lib/stores/auth.svelte.ts | 1 + .../routes/(dashboard)/perfil/+page.svelte | 893 ++++++++---------- .../(dashboard)/recursos-humanos/+page.svelte | 28 + .../atestados-licencas/+page.svelte | 91 ++ .../recursos-humanos/ferias/+page.svelte | 285 ++++++ .../funcionarios/+page.svelte | 53 -- .../funcionarios/[funcionarioId]/+page.svelte | 301 +++++- .../[funcionarioId]/editar/+page.svelte | 235 +++++ .../funcionarios/cadastro/+page.svelte | 186 +++- .../routes/(dashboard)/ti/times/+page.svelte | 505 ++++++++++ packages/backend/convex/_generated/api.d.ts | 8 + packages/backend/convex/crons.ts | 8 + packages/backend/convex/cursos.ts | 67 ++ packages/backend/convex/ferias.ts | 475 ++++++++++ packages/backend/convex/funcionarios.ts | 75 +- packages/backend/convex/migrarParaTimes.ts | 171 ++++ packages/backend/convex/schema.ts | 94 +- packages/backend/convex/times.ts | 270 ++++++ 21 files changed, 3910 insertions(+), 617 deletions(-) create mode 100644 apps/web/src/lib/components/AprovarFerias.svelte create mode 100644 apps/web/src/lib/components/SolicitarFerias.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/times/+page.svelte create mode 100644 packages/backend/convex/cursos.ts create mode 100644 packages/backend/convex/ferias.ts create mode 100644 packages/backend/convex/migrarParaTimes.ts create mode 100644 packages/backend/convex/times.ts diff --git a/apps/web/src/lib/components/AprovarFerias.svelte b/apps/web/src/lib/components/AprovarFerias.svelte new file mode 100644 index 0000000..18f45e3 --- /dev/null +++ b/apps/web/src/lib/components/AprovarFerias.svelte @@ -0,0 +1,378 @@ + + +
+
+
+
+

+ {solicitacao.funcionario?.nome || "Funcionário"} +

+

+ Ano de Referência: {solicitacao.anoReferencia} +

+
+
+ {getStatusTexto(solicitacao.status)} +
+
+ + +
+

Períodos Solicitados

+
+ {#each solicitacao.periodos as periodo, index} +
+
{index + 1}
+
+
+ Início: + {new Date(periodo.dataInicio).toLocaleDateString("pt-BR")} +
+
+ Fim: + {new Date(periodo.dataFim).toLocaleDateString("pt-BR")} +
+
+ Dias: + {periodo.diasCorridos} +
+
+
+ {/each} +
+
+ + + {#if solicitacao.observacao} +
+

Observações

+
+ {solicitacao.observacao} +
+
+ {/if} + + + {#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0} +
+

Histórico

+
+ {#each solicitacao.historicoAlteracoes as hist} +
+ + + + {formatarData(hist.data)} + - + {hist.acao} +
+ {/each} +
+
+ {/if} + + + {#if solicitacao.status === "aguardando_aprovacao"} +
+ + {#if !modoAjuste} + +
+
+ + + +
+ + +
+
+

Reprovar Solicitação

+ + +
+
+
+ {:else} + +
+

Ajustar Períodos

+ {#each periodos as periodo, index} +
+
+
Período {index + 1}
+
+
+ + calcularDias(periodo)} + /> +
+
+ + calcularDias(periodo)} + /> +
+
+ +
+ {periodo.diasCorridos} +
+
+
+
+
+ {/each} + +
+ + +
+
+ {/if} + {/if} + + + {#if solicitacao.status === "reprovado" && solicitacao.motivoReprovacao} +
+ + + +
+
Motivo da Reprovação:
+
{solicitacao.motivoReprovacao}
+
+
+ {/if} + + + {#if erro} +
+ + + + {erro} +
+ {/if} + + + {#if onCancelar} +
+ +
+ {/if} +
+
+ diff --git a/apps/web/src/lib/components/SolicitarFerias.svelte b/apps/web/src/lib/components/SolicitarFerias.svelte new file mode 100644 index 0000000..c61f7bc --- /dev/null +++ b/apps/web/src/lib/components/SolicitarFerias.svelte @@ -0,0 +1,304 @@ + + +
+
+

+ + + + Solicitar Férias +

+ + +
+ + +
+ + +
+
+

Períodos ({periodos.length}/3)

+ {#if periodos.length < 3} + + {/if} +
+ +
+ {#each periodos as periodo, index} +
+
+
+

Período {index + 1}

+ {#if periodos.length > 1} + + {/if} +
+ +
+
+ + calcularDias(periodo)} + /> +
+ +
+ + calcularDias(periodo)} + /> +
+ +
+ +
+ {periodo.diasCorridos} + dias +
+
+
+
+
+ {/each} +
+
+ + +
+ + +
+ + + {#if erro} +
+ + + + {erro} +
+ {/if} + + +
+ {#if onCancelar} + + {/if} + +
+
+
+ diff --git a/apps/web/src/lib/components/chat/NotificationBell.svelte b/apps/web/src/lib/components/chat/NotificationBell.svelte index ee5fd58..09d9abd 100644 --- a/apps/web/src/lib/components/chat/NotificationBell.svelte +++ b/apps/web/src/lib/components/chat/NotificationBell.svelte @@ -8,16 +8,42 @@ // Queries e Client const client = useConvexClient(); - const notificacoes = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true }); - const count = useQuery(api.chat.contarNotificacoesNaoLidas, {}); + const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true }); + const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {}); let dropdownOpen = $state(false); + let notificacoesFerias = $state([]); + + // Helpers para obter valores das queries + const count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0); + const notificacoes = $derived((Array.isArray(notificacoesQuery) ? notificacoesQuery : notificacoesQuery?.data) ?? []); // Atualizar contador no store $effect(() => { - if (count !== undefined) { - notificacoesCount.set(count); + const totalNotificacoes = count + (notificacoesFerias?.length || 0); + notificacoesCount.set(totalNotificacoes); + }); + + // Buscar notificações de férias + async function buscarNotificacoesFerias() { + try { + const usuarioStore = await import("$lib/stores/auth.svelte").then(m => m.authStore); + if (usuarioStore.usuario?._id) { + const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, { + usuarioId: usuarioStore.usuario._id as any, + }); + notificacoesFerias = notifsFerias || []; + } + } catch (e) { + console.error("Erro ao buscar notificações de férias:", e); } + } + + // Atualizar notificações de férias periodicamente + $effect(() => { + buscarNotificacoesFerias(); + const interval = setInterval(buscarNotificacoesFerias, 30000); // A cada 30s + return () => clearInterval(interval); }); function formatarTempo(timestamp: number): string { @@ -33,7 +59,12 @@ async function handleMarcarTodasLidas() { await client.mutation(api.chat.marcarTodasNotificacoesLidas, {}); + // Marcar todas as notificações de férias como lidas + for (const notif of notificacoesFerias) { + await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notif._id }); + } dropdownOpen = false; + await buscarNotificacoesFerias(); } async function handleClickNotificacao(notificacaoId: string) { @@ -41,6 +72,14 @@ dropdownOpen = false; } + async function handleClickNotificacaoFerias(notificacaoId: string) { + await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notificacaoId as any }); + await buscarNotificacoesFerias(); + dropdownOpen = false; + // Redirecionar para a página de férias + window.location.href = "/recursos-humanos/ferias"; + } + function toggleDropdown() { dropdownOpen = !dropdownOpen; } @@ -101,12 +140,13 @@ - {#if count && count > 0} + {#if count + (notificacoesFerias?.length || 0) > 0} + {@const totalCount = count + (notificacoesFerias?.length || 0)} - {count > 9 ? "9+" : count} + {totalCount > 9 ? "9+" : totalCount} {/if} @@ -119,7 +159,7 @@

Notificações

- {#if count && count > 0} + {#if count > 0} {/each} - {:else} + {/if} + + + {#if notificacoesFerias.length > 0} + {#if notificacoes.length > 0} +
Férias
+ {/if} + {#each notificacoesFerias.slice(0, 5) as notificacao (notificacao._id)} + + {/each} + {/if} + + + {#if notificacoes.length === 0 && notificacoesFerias.length === 0}
- import { useQuery, useConvexClient } from "convex-svelte"; + import { useConvexClient, useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; - import { requestNotificationPermission } from "$lib/utils/notifications"; - import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator"; - + import { authStore } from "$lib/stores/auth.svelte"; + import SolicitarFerias from "$lib/components/SolicitarFerias.svelte"; + import AprovarFerias from "$lib/components/AprovarFerias.svelte"; + const client = useConvexClient(); - const perfil = useQuery(api.usuarios.obterPerfil, {}); - - // Estados - let nome = $state(""); - let email = $state(""); - let matricula = $state(""); - let avatarSelecionado = $state(""); - let statusMensagemInput = $state(""); - let statusPresencaSelect = $state("online"); - let notificacoesAtivadas = $state(true); - let somNotificacao = $state(true); - let uploadingFoto = $state(false); - let salvando = $state(false); - let mensagemSucesso = $state(""); - - // Sincronizar com perfil - $effect(() => { - if (perfil) { - nome = perfil.nome || ""; - email = perfil.email || ""; - matricula = perfil.matricula || ""; - avatarSelecionado = perfil.avatar || ""; - statusMensagemInput = perfil.statusMensagem || ""; - statusPresencaSelect = perfil.statusPresenca || "online"; - notificacoesAtivadas = perfil.notificacoesAtivadas ?? true; - somNotificacao = perfil.somNotificacao ?? true; - } - }); - - // Lista de avatares profissionais usando DiceBear - TODOS FELIZES E SORRIDENTES - const avatares = [ - // Avatares masculinos (16) - { id: "avatar-m-1", seed: "John-Happy", label: "Homem 1" }, - { id: "avatar-m-2", seed: "Peter-Smile", label: "Homem 2" }, - { id: "avatar-m-3", seed: "Michael-Joy", label: "Homem 3" }, - { id: "avatar-m-4", seed: "David-Glad", label: "Homem 4" }, - { id: "avatar-m-5", seed: "James-Cheerful", label: "Homem 5" }, - { id: "avatar-m-6", seed: "Robert-Bright", label: "Homem 6" }, - { id: "avatar-m-7", seed: "William-Joyful", label: "Homem 7" }, - { id: "avatar-m-8", seed: "Joseph-Merry", label: "Homem 8" }, - { id: "avatar-m-9", seed: "Thomas-Happy", label: "Homem 9" }, - { id: "avatar-m-10", seed: "Charles-Smile", label: "Homem 10" }, - { id: "avatar-m-11", seed: "Daniel-Joy", label: "Homem 11" }, - { id: "avatar-m-12", seed: "Matthew-Glad", label: "Homem 12" }, - { id: "avatar-m-13", seed: "Anthony-Cheerful", label: "Homem 13" }, - { id: "avatar-m-14", seed: "Mark-Bright", label: "Homem 14" }, - { id: "avatar-m-15", seed: "Donald-Joyful", label: "Homem 15" }, - { id: "avatar-m-16", seed: "Steven-Merry", label: "Homem 16" }, - - // Avatares femininos (16) - { id: "avatar-f-1", seed: "Maria-Happy", label: "Mulher 1" }, - { id: "avatar-f-2", seed: "Ana-Smile", label: "Mulher 2" }, - { id: "avatar-f-3", seed: "Patricia-Joy", label: "Mulher 3" }, - { id: "avatar-f-4", seed: "Jennifer-Glad", label: "Mulher 4" }, - { id: "avatar-f-5", seed: "Linda-Cheerful", label: "Mulher 5" }, - { id: "avatar-f-6", seed: "Barbara-Bright", label: "Mulher 6" }, - { id: "avatar-f-7", seed: "Elizabeth-Joyful", label: "Mulher 7" }, - { id: "avatar-f-8", seed: "Jessica-Merry", label: "Mulher 8" }, - { id: "avatar-f-9", seed: "Sarah-Happy", label: "Mulher 9" }, - { id: "avatar-f-10", seed: "Karen-Smile", label: "Mulher 10" }, - { id: "avatar-f-11", seed: "Nancy-Joy", label: "Mulher 11" }, - { id: "avatar-f-12", seed: "Betty-Glad", label: "Mulher 12" }, - { id: "avatar-f-13", seed: "Helen-Cheerful", label: "Mulher 13" }, - { id: "avatar-f-14", seed: "Sandra-Bright", label: "Mulher 14" }, - { id: "avatar-f-15", seed: "Ashley-Joyful", label: "Mulher 15" }, - { id: "avatar-f-16", seed: "Kimberly-Merry", label: "Mulher 16" }, - ]; + let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "aprovar-ferias">("meu-perfil"); + let mostrarFormSolicitar = $state(false); + let solicitacaoSelecionada = $state(null); - function getAvatarUrl(avatarId: string): string { - // Usar gerador local ao invés da API externa - return generateAvatarUrl(avatarId); + // Queries + const funcionarioQuery = $derived( + authStore.usuario?.funcionarioId + ? useQuery(api.funcionarios.getById, { id: authStore.usuario.funcionarioId as any }) + : { data: null } + ); + + const minhasSolicitacoesQuery = $derived( + funcionarioQuery.data + ? useQuery(api.ferias.listarMinhasSolicitacoes, { funcionarioId: funcionarioQuery.data._id }) + : { data: [] } + ); + + const solicitacoesSubordinadosQuery = $derived( + authStore.usuario?._id + ? useQuery(api.ferias.listarSolicitacoesSubordinados, { gestorId: authStore.usuario._id as any }) + : { data: [] } + ); + + const meuTimeQuery = $derived( + funcionarioQuery.data + ? useQuery(api.times.obterTimeFuncionario, { funcionarioId: funcionarioQuery.data._id }) + : { data: null } + ); + + const meusTimesGestorQuery = $derived( + authStore.usuario?._id + ? useQuery(api.times.listarPorGestor, { gestorId: authStore.usuario._id as any }) + : { data: [] } + ); + + const funcionario = $derived(funcionarioQuery.data); + const minhasSolicitacoes = $derived(minhasSolicitacoesQuery?.data || []); + const solicitacoesSubordinados = $derived(solicitacoesSubordinadosQuery?.data || []); + const meuTime = $derived(meuTimeQuery?.data); + const meusTimesGestor = $derived(meusTimesGestorQuery?.data || []); + + // Verificar se é gestor + const ehGestor = $derived((meusTimesGestor || []).length > 0); + + async function recarregar() { + mostrarFormSolicitar = false; + solicitacaoSelecionada = null; } - - async function handleUploadFoto(e: Event) { - const input = e.target as HTMLInputElement; - const file = input.files?.[0]; - if (!file) return; - - // Validar tipo - if (!file.type.startsWith("image/")) { - alert("Por favor, selecione uma imagem"); - return; - } - - // Validar tamanho (max 2MB) - if (file.size > 2 * 1024 * 1024) { - alert("A imagem deve ter no máximo 2MB"); - return; - } - - try { - uploadingFoto = true; - - // 1. Obter upload URL - const uploadUrl = await client.mutation(api.usuarios.uploadFotoPerfil, {}); - - // 2. Upload da foto - const result = await fetch(uploadUrl, { - method: "POST", - headers: { "Content-Type": file.type }, - body: file, - }); - - if (!result.ok) { - throw new Error("Falha no upload"); - } - - const { storageId } = await result.json(); - - // 3. Atualizar perfil - await client.mutation(api.usuarios.atualizarPerfil, { - fotoPerfil: storageId, - avatar: "", // Limpar avatar quando usa foto - }); - - mensagemSucesso = "Foto de perfil atualizada com sucesso!"; - setTimeout(() => (mensagemSucesso = ""), 3000); - } catch (error) { - console.error("Erro ao fazer upload:", error); - alert("Erro ao fazer upload da foto"); - } finally { - uploadingFoto = false; - input.value = ""; - } + + async function selecionarSolicitacao(solicitacaoId: string) { + const detalhes = await client.query(api.ferias.obterDetalhes, { + solicitacaoId: solicitacaoId as any, + }); + solicitacaoSelecionada = detalhes; } - - async function handleSelecionarAvatar(avatarId: string) { - try { - avatarSelecionado = avatarId; - await client.mutation(api.usuarios.atualizarPerfil, { - avatar: avatarId, - fotoPerfil: undefined, // Limpar foto quando usa avatar - }); - mensagemSucesso = "Avatar atualizado com sucesso!"; - setTimeout(() => (mensagemSucesso = ""), 3000); - } catch (error) { - console.error("Erro ao atualizar avatar:", error); - alert("Erro ao atualizar avatar"); - } + + function getStatusBadge(status: string) { + const badges: Record = { + aguardando_aprovacao: "badge-warning", + aprovado: "badge-success", + reprovado: "badge-error", + data_ajustada_aprovada: "badge-info", + }; + return badges[status] || "badge-neutral"; } - - async function handleSalvarConfiguracoes() { - try { - salvando = true; - - // Validar statusMensagem - if (statusMensagemInput.length > 100) { - alert("A mensagem de status deve ter no máximo 100 caracteres"); - return; - } - - await client.mutation(api.usuarios.atualizarPerfil, { - statusMensagem: statusMensagemInput.trim() || undefined, - statusPresenca: statusPresencaSelect as any, - notificacoesAtivadas, - somNotificacao, - }); - - mensagemSucesso = "Configurações salvas com sucesso!"; - setTimeout(() => (mensagemSucesso = ""), 3000); - } catch (error) { - console.error("Erro ao salvar configurações:", error); - alert("Erro ao salvar configurações"); - } finally { - salvando = false; - } - } - - async function handleSolicitarNotificacoes() { - const permission = await requestNotificationPermission(); - if (permission === "granted") { - await client.mutation(api.usuarios.atualizarPerfil, { notificacoesAtivadas: true }); - notificacoesAtivadas = true; - } else if (permission === "denied") { - alert( - "Você negou as notificações. Para ativá-las, permita notificações nas configurações do navegador." - ); - } + + function getStatusTexto(status: string) { + const textos: Record = { + aguardando_aprovacao: "Aguardando", + aprovado: "Aprovado", + reprovado: "Reprovado", + data_ajustada_aprovada: "Ajustado", + }; + return textos[status] || status; } -
+
+
-

Meu Perfil

-

Gerencie suas informações e preferências

+
+
+
+ {authStore.usuario?.nome.substring(0, 2).toUpperCase()} +
+
+
+

{authStore.usuario?.nome}

+

{authStore.usuario?.email}

+ {#if meuTime} +
+ + + + {meuTime.nome} +
+ {/if} +
+
- {#if mensagemSucesso} -
- - + +
+
- {/if} + Meu Perfil + + + + + {#if ehGestor} + + {/if} +
- {#if perfil} -
- + + {#if abaAtiva === "meu-perfil"} + +
+
-

Foto de Perfil

- -
- -
- {#if perfil.fotoPerfilUrl} -
-
- Foto de perfil -
-
- {:else if perfil.avatar || avatarSelecionado} -
-
- Avatar -
-
- {:else} -
-
- - - -
-
- {/if} -
- - -
- -

- Máximo 2MB. Formatos: JPG, PNG, GIF, WEBP -

-
-
- - -
OU escolha um avatar profissional
-
- - - +

Informações Pessoais

+
-

32 avatares disponíveis - Todos felizes e sorridentes! 😊

-
-
-
- {#each avatares as avatar} - - {/each} -
-
-
- - -
-
-

Informações Básicas

-

- Informações do seu cadastro (somente leitura) -

- -
-
- - + +

{authStore.usuario?.nome}

- -
- - +
+ + Email + +

{authStore.usuario?.email}

- -
- - +
+ + Perfil + +
{authStore.usuario?.role?.nome || "Usuário"}
- -
- -
- - - -
- + + {#if funcionario} +
+
+

Dados Funcionais

+
+
+ + Matrícula + +

{funcionario.matricula || "Não informada"}

+
+
+ + CPF + +

{funcionario.cpf}

+
+
+ + Time + + {#if meuTime} +
+
+ {meuTime.nome} +
+ Gestor: {meuTime.gestor?.nome} +
+ {:else} +

Não atribuído a um time

+ {/if} +
+
+ + Status + + {#if funcionario.statusFerias === "em_ferias"} +
🏖️ Em Férias
+ {:else} +
✅ Ativo
+ {/if} +
+
+
+
+ {/if} + + + {#if ehGestor} +
+
+

+ + + + Times que Você Gerencia +

+
+ {#each meusTimesGestor as time} +
+
+

{time.nome}

+

{time.descricao || "Sem descrição"}

+
+ + + + {time.membros?.length || 0} membros +
+
+
+ {/each} +
+
+
+ {/if} +
+ + {:else if abaAtiva === "minhas-ferias"} + +
+
-

Preferências de Chat

- -
- - -
- -
- -
- -
- - {#if notificacoesAtivadas && typeof Notification !== "undefined" && Notification.permission !== "granted"} -
- - - - Você precisa permitir notificações no navegador - +
+
+

Minhas Solicitações de Férias

+

Solicite e acompanhe suas férias

- {/if} - -
- -
- -
+ + {#if mostrarFormSolicitar} +
+ {#if funcionario} + + {:else} +
+ + + +
+

Perfil de funcionário não encontrado

+
Seu usuário ainda não está associado a um cadastro de funcionário. Entre em contato com o RH.
+
+
+ {/if} + {/if} +
+
+ + +
+
+

Histórico ({minhasSolicitacoes.length})

+ + {#if minhasSolicitacoes.length === 0} +
+ + + + Você ainda não tem solicitações de férias. +
+ {:else} +
+ {#each minhasSolicitacoes as solicitacao} +
+
+
+
+
+

Férias {solicitacao.anoReferencia}

+
+ {getStatusTexto(solicitacao.status)} +
+
+
+

Períodos: {solicitacao.periodos.length}

+

Total: {solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} dias

+ {#if solicitacao.motivoReprovacao} +

Motivo: {solicitacao.motivoReprovacao}

+ {/if} +
+
+
+ Solicitado em
+ {new Date(solicitacao._creationTime).toLocaleDateString("pt-BR")} +
+
+
+
+ {/each} +
+ {/if}
- {:else} - -
- + + {:else if abaAtiva === "aprovar-ferias"} + +
+
+

+ Solicitações da Equipe ({solicitacoesSubordinados.length}) +

+ + {#if solicitacoesSubordinados.length === 0} +
+ + + + Nenhuma solicitação pendente no momento. +
+ {:else} +
+ + + + + + + + + + + + + + {#each solicitacoesSubordinados as solicitacao} + + + + + + + + + + {/each} + +
FuncionárioTimeAnoPeríodosDiasStatusAções
+
{solicitacao.funcionario?.nome}
+
+ {#if solicitacao.time} +
+ {solicitacao.time.nome} +
+ {/if} +
{solicitacao.anoReferencia}{solicitacao.periodos.length}{solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} +
+ {getStatusTexto(solicitacao.status)} +
+
+ {#if solicitacao.status === "aguardando_aprovacao"} + + {:else} + + {/if} +
+
+ {/if} +
{/if} -
+ + + {#if solicitacaoSelecionada} + + + + + {/if} +
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index 9de6dca..b9b2415 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -79,6 +79,34 @@ }, ], }, + { + categoria: "Gestão de Férias e Licenças", + descricao: "Controle de férias, atestados e licenças", + icon: ` + + `, + gradient: "from-purple-500/10 to-purple-600/20", + accentColor: "text-purple-600", + bgIcon: "bg-purple-500/20", + opcoes: [ + { + nome: "Gestão de Férias", + descricao: "Controlar períodos de férias", + href: "/recursos-humanos/ferias", + icon: ` + + `, + }, + { + nome: "Atestados & Licenças", + descricao: "Registrar atestados e licenças", + href: "/recursos-humanos/atestados-licencas", + icon: ` + + `, + }, + ], + }, ]; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte new file mode 100644 index 0000000..9b7d77c --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte @@ -0,0 +1,91 @@ + + +
+ + + + +
+
+
+
+ + + +
+
+

Atestados & Licenças

+

Registro de atestados médicos e licenças

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

Módulo em Desenvolvimento

+
Esta funcionalidade está em desenvolvimento e estará disponível em breve.
+
+
+ + +
+
+
+

Registrar Atestado

+

Cadastre atestados médicos

+
+ +
+
+
+ +
+
+

Registrar Licença

+

Cadastre licenças e afastamentos

+
+ +
+
+
+ +
+
+

Histórico

+

Consulte histórico de atestados e licenças

+
+ +
+
+
+ +
+
+

Estatísticas

+

Visualize estatísticas e relatórios

+
+ +
+
+
+
+
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte new file mode 100644 index 0000000..8b95711 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte @@ -0,0 +1,285 @@ + + +
+ + + + +
+
+
+
+ + + +
+
+

Dashboard de Férias

+

Visão geral de todas as solicitações e funcionários

+
+
+ +
+
+ + +
+
+
+ + + +
+
Total
+
{stats.total}
+
Solicitações
+
+ +
+
+ + + +
+
Aguardando
+
{stats.aguardando}
+
Pendentes
+
+ +
+
+ + + +
+
Aprovadas
+
{stats.aprovadas}
+
Deferidas
+
+ +
+
+ + + +
+
Reprovadas
+
{stats.reprovadas}
+
Indeferidas
+
+ +
+
+ + + +
+
Em Férias
+
{stats.emFerias}
+
Agora
+
+
+ + +
+
+

Filtros

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

+ Solicitações ({solicitacoesFiltradas.length}) +

+ + {#if solicitacoesFiltradas.length === 0} +
+ + + + Nenhuma solicitação encontrada com os filtros aplicados. +
+ {:else} +
+ + + + + + + + + + + + + + {#each solicitacoesFiltradas as solicitacao} + + + + + + + + + + {/each} + +
FuncionárioTimeAnoPeríodosTotal DiasStatusSolicitado em
+
+
+
+ {solicitacao.funcionario?.nome.substring(0, 2).toUpperCase()} +
+
+
+
{solicitacao.funcionario?.nome}
+
{solicitacao.funcionario?.matricula || "S/N"}
+
+
+
+ {#if solicitacao.time} +
+ {solicitacao.time.nome} +
+ {:else} + Sem time + {/if} +
{solicitacao.anoReferencia}{solicitacao.periodos.length} período(s){solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} dias +
+ {getStatusTexto(solicitacao.status)} +
+
{new Date(solicitacao._creationTime).toLocaleDateString("pt-BR")}
+
+ {/if} +
+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte index c6637d2..54a14f0 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte @@ -10,8 +10,6 @@ let list: Array = []; let filtered: Array = []; let selectedId: string | null = null; - let deletingId: string | null = null; - let toDelete: { id: string; nome: string } | null = null; let openMenuId: string | null = null; let funcionarioParaImprimir: any = null; @@ -42,15 +40,6 @@ if (selectedId) goto(`/recursos-humanos/funcionarios/${selectedId}/editar`); } - function openDeleteModal(id: string, nome: string) { - toDelete = { id, nome }; - (document.getElementById("delete_modal_func") as HTMLDialogElement)?.showModal(); - } - function closeDeleteModal() { - toDelete = null; - (document.getElementById("delete_modal_func") as HTMLDialogElement)?.close(); - } - async function openPrintModal(funcionarioId: string) { try { const data = await client.query(api.funcionarios.getFichaCompleta, { @@ -62,17 +51,6 @@ alert("Erro ao carregar dados para impressão"); } } - async function confirmDelete() { - if (!toDelete) return; - try { - deletingId = toDelete.id; - await client.mutation(api.funcionarios.remove, { id: toDelete.id } as any); - closeDeleteModal(); - await load(); - } finally { - deletingId = null; - } - } function navCadastro() { goto("/recursos-humanos/funcionarios/cadastro"); } @@ -231,7 +209,6 @@
  • Editar
  • Ver Documentos
  • -
  • @@ -249,36 +226,6 @@ Exibindo {filtered.length} de {list.length} funcionário(s)
    - - - - - - {#if funcionarioParaImprimir} (null); let simbolo = $state(null); + let cursos = $state([]); let documentosUrls = $state>({}); let loading = $state(true); let showPrintModal = $state(false); + let showPrintFinanceiro = $state(false); async function load() { try { @@ -35,6 +37,7 @@ funcionario = data; simbolo = data.simbolo; + cursos = data.cursos || []; // Carregar URLs dos documentos try { @@ -126,12 +129,87 @@ Imprimir Ficha + +
    + + + + + {#if simbolo} +
    +
    +

    + + + + Dados Financeiros +

    +
    +
    +
    Símbolo
    +
    {simbolo.nome}
    +
    {simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}
    +
    + {#if funcionario.simboloTipo === 'cargo_comissionado'} +
    +
    Vencimento
    +
    R$ {simbolo.vencValor}
    +
    Valor base
    +
    +
    +
    Representação
    +
    R$ {simbolo.repValor}
    +
    Adicional
    +
    + {/if} +
    +
    Total
    +
    R$ {simbolo.valor}
    +
    Remuneração total
    +
    +
    +
    +
    + {/if} + + +
    +
    +
    +
    +
    + + + +
    +
    +

    Status Atual

    +
    + {#if funcionario.statusFerias === "em_ferias"} +
    🏖️ Em Férias
    + {:else} +
    ✅ Ativo
    + {/if} +
    +
    +
    + + + + + Gerenciar Férias +
    -
    +
    @@ -196,8 +274,45 @@ {/if}
    - +
    + +
    +
    +

    Cargo e Vínculo

    +
    +
    Tipo: {funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}
    + {#if simbolo} +
    Símbolo: {simbolo.nome}
    +
    {simbolo.descricao}
    + {/if} + {#if funcionario.descricaoCargo} +
    Descrição: {funcionario.descricaoCargo}
    + {/if} + {#if funcionario.admissaoData} +
    Data Admissão: {funcionario.admissaoData}
    + {/if} + {#if funcionario.nomeacaoPortaria} +
    Portaria: {funcionario.nomeacaoPortaria}
    + {/if} + {#if funcionario.nomeacaoData} +
    Data Nomeação: {funcionario.nomeacaoData}
    + {/if} + {#if funcionario.nomeacaoDOE} +
    DOE: {funcionario.nomeacaoDOE}
    + {/if} + {#if funcionario.pertenceOrgaoPublico} +
    Pertence Órgão Público: Sim
    + {#if funcionario.orgaoOrigem} +
    Órgão Origem: {funcionario.orgaoOrigem}
    + {/if} + {/if} + {#if funcionario.aposentado && funcionario.aposentado !== 'nao'} +
    Aposentado: {getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)}
    + {/if} +
    +
    +
    @@ -253,6 +368,48 @@
    {/if} + + {#if cursos && cursos.length > 0} +
    +
    +

    + + + + Cursos e Treinamentos +

    +
    + {#each cursos as curso} +
    +
    +

    {curso.descricao}

    +

    + + + + {curso.data} +

    +
    + {#if curso.certificadoUrl} + + + + + Certificado + + {/if} +
    + {/each} +
    +
    +
    + {/if} + {#if funcionario.grupoSanguineo || funcionario.fatorRH}
    @@ -280,47 +437,6 @@
    -
    - - -
    - -
    -
    -

    Cargo e Vínculo

    -
    -
    Tipo: {funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}
    - {#if simbolo} -
    Símbolo: {simbolo.nome}
    -
    {simbolo.descricao}
    - {/if} - {#if funcionario.descricaoCargo} -
    Descrição: {funcionario.descricaoCargo}
    - {/if} - {#if funcionario.admissaoData} -
    Data Admissão: {funcionario.admissaoData}
    - {/if} - {#if funcionario.nomeacaoPortaria} -
    Portaria: {funcionario.nomeacaoPortaria}
    - {/if} - {#if funcionario.nomeacaoData} -
    Data Nomeação: {funcionario.nomeacaoData}
    - {/if} - {#if funcionario.nomeacaoDOE} -
    DOE: {funcionario.nomeacaoDOE}
    - {/if} - {#if funcionario.pertenceOrgaoPublico} -
    Pertence Órgão Público: Sim
    - {#if funcionario.orgaoOrigem} -
    Órgão Origem: {funcionario.orgaoOrigem}
    - {/if} - {/if} - {#if funcionario.aposentado && funcionario.aposentado !== 'nao'} -
    Aposentado: {getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)}
    - {/if} -
    -
    -
    @@ -431,4 +547,103 @@ onClose={() => showPrintModal = false} /> {/if} + + + {#if showPrintFinanceiro && simbolo} + + + + + {/if} {/if} diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/editar/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/editar/+page.svelte index c0dc440..196dc19 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/editar/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/editar/+page.svelte @@ -92,6 +92,25 @@ // Documentos (Storage IDs) let documentosStorage: Record = $state({}); + + // Cursos e Treinamentos + interface Curso { + _id?: string; + id: string; + descricao: string; + data: string; + certificadoId?: string; + arquivo?: File; + marcadoParaExcluir?: boolean; + } + + let cursos = $state([]); + let mostrarFormularioCurso = $state(false); + let cursoAtual = $state({ + id: crypto.randomUUID(), + descricao: "", + data: "", + }); async function loadSimbolos() { const list = await client.query(api.simbolos.getAll, {} as any); @@ -170,6 +189,22 @@ documentosStorage[doc.campo] = storageId; } }); + + // Carregar cursos + try { + const cursosData = await client.query(api.cursos.listarPorFuncionario, { + funcionarioId: funcionarioId as any, + }); + cursos = cursosData.map((c: any) => ({ + _id: c._id, + id: c._id, + descricao: c.descricao, + data: c.data, + certificadoId: c.certificadoId, + })); + } catch (error) { + console.error("Erro ao carregar cursos:", error); + } } catch (error) { console.error("Erro ao carregar funcionário:", error); notice = { kind: "error", text: "Erro ao carregar dados do funcionário" }; @@ -193,6 +228,51 @@ uf = data.uf || ""; } catch {} } + + // Funções de Cursos + function adicionarCurso() { + if (!cursoAtual.descricao.trim() || !cursoAtual.data.trim()) { + notice = { kind: "error", text: "Preencha a descrição e data do curso" }; + return; + } + + if (cursos.filter(c => !c.marcadoParaExcluir).length >= 7) { + notice = { kind: "error", text: "Máximo de 7 cursos permitidos" }; + return; + } + + cursos.push({ ...cursoAtual }); + cursoAtual = { + id: crypto.randomUUID(), + descricao: "", + data: "", + }; + mostrarFormularioCurso = false; + } + + function removerCurso(id: string) { + const curso = cursos.find(c => c.id === id); + if (curso && curso._id) { + // Marcar para excluir se já existe no banco + curso.marcadoParaExcluir = true; + } else { + // Remover diretamente se é novo + cursos = cursos.filter(c => c.id !== id); + } + } + + async function uploadCertificado(file: File): Promise { + const uploadUrl = await client.mutation(api.documentos.generateUploadUrl, {}); + + const result = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + + const { storageId } = await result.json(); + return storageId; + } async function handleDocumentoUpload(campo: string, file: File) { try { @@ -299,6 +379,45 @@ }; await client.mutation(api.funcionarios.update, { id: funcionarioId as any, ...payload as any }); + + // Salvar cursos + try { + // Excluir cursos marcados + for (const curso of cursos.filter(c => c.marcadoParaExcluir && c._id)) { + await client.mutation(api.cursos.excluir, { id: curso._id as any }); + } + + // Adicionar/atualizar cursos + for (const curso of cursos.filter(c => !c.marcadoParaExcluir)) { + let certificadoId = curso.certificadoId; + + // Upload de certificado se houver arquivo novo + if (curso.arquivo) { + certificadoId = await uploadCertificado(curso.arquivo); + } + + if (curso._id) { + // Atualizar curso existente + await client.mutation(api.cursos.atualizar, { + id: curso._id as any, + descricao: curso.descricao, + data: curso.data, + certificadoId: certificadoId as any, + }); + } else { + // Criar novo curso + await client.mutation(api.cursos.criar, { + funcionarioId: funcionarioId as any, + descricao: curso.descricao, + data: curso.data, + certificadoId: certificadoId as any, + }); + } + } + } catch (error) { + console.error("Erro ao salvar cursos:", error); + } + notice = { kind: "success", text: "Funcionário atualizado com sucesso!" }; setTimeout(() => goto("/recursos-humanos/funcionarios"), 600); } catch (e: any) { @@ -1254,6 +1373,122 @@
    + +
    +
    +

    + + + + Cursos e Treinamentos +

    + +

    + Gerencie cursos e treinamentos do funcionário (até 7 cursos) +

    + + {#if cursos.filter(c => !c.marcadoParaExcluir).length > 0} +
    +

    Cursos cadastrados ({cursos.filter(c => !c.marcadoParaExcluir).length}/7)

    + {#each cursos.filter(c => !c.marcadoParaExcluir) as curso} +
    +
    +

    {curso.descricao}

    +

    {curso.data}

    + {#if curso.certificadoId} +

    ✓ Com certificado

    + {/if} +
    + +
    + {/each} +
    + {/if} + + {#if cursos.filter(c => !c.marcadoParaExcluir).length < 7} +
    + +
    + Adicionar Curso/Treinamento +
    +
    +
    +
    + + +
    + +
    + + cursoAtual.data = maskDate(e.currentTarget.value)} + /> +
    + +
    + + { + const file = e.currentTarget.files?.[0]; + if (file) cursoAtual.arquivo = file; + }} + /> +
    + + +
    +
    +
    + {:else} +
    + + + + Limite de 7 cursos atingido +
    + {/if} +
    +
    +
    diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte index d064f56..90dda4c 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte @@ -89,6 +89,46 @@ // Documentos (Storage IDs) let documentosStorage: Record = $state({}); + // Cursos e Treinamentos + let cursos = $state>([]); + let mostrarFormularioCurso = $state(false); + let cursoAtual = $state({ descricao: "", data: "", arquivo: null as File | null }); + + function adicionarCurso() { + if (!cursoAtual.descricao.trim() || !cursoAtual.data.trim()) { + alert("Preencha a descrição e a data do curso"); + return; + } + cursos.push({ + id: crypto.randomUUID(), + descricao: cursoAtual.descricao, + data: cursoAtual.data, + certificadoId: undefined + }); + cursoAtual = { descricao: "", data: "", arquivo: null }; + } + + function removerCurso(id: string) { + cursos = cursos.filter(c => c.id !== id); + } + + async function uploadCertificado(file: File): Promise { + const storageId = await client.mutation(api.documentos.generateUploadUrl, {}); + const uploadUrl = await client.mutation(api.documentos.generateUploadUrl, {}); + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + const result = await response.json(); + return result.storageId; + } + async function loadSimbolos() { const list = await client.query(api.simbolos.getAll, {} as any); simbolos = list.map((s: any) => ({ @@ -140,7 +180,7 @@ async function handleSubmit() { // Validação básica - if (!nome || !matricula || !cpf || !rg || !nascimento || !email || !telefone) { + if (!nome || !cpf || !rg || !nascimento || !email || !telefone) { notice = { kind: "error", text: "Preencha todos os campos obrigatórios" }; return; } @@ -165,7 +205,7 @@ const payload = { nome, - matricula, + matricula: matricula.trim() || undefined, cpf: onlyDigits(cpf), rg: onlyDigits(rg), nascimento, @@ -229,7 +269,28 @@ ), }; - await client.mutation(api.funcionarios.create, payload as any); + const novoFuncionarioId = await client.mutation(api.funcionarios.create, payload as any); + + // Salvar cursos, se houver + for (const curso of cursos) { + let certificadoId = curso.certificadoId; + // Se houver arquivo para upload, fazer o upload + if (cursoAtual.arquivo && curso.id === cursos[cursos.length - 1].id) { + try { + certificadoId = await uploadCertificado(cursoAtual.arquivo); + } catch (err) { + console.error("Erro ao fazer upload do certificado:", err); + } + } + + await client.mutation(api.cursos.criar, { + funcionarioId: novoFuncionarioId, + descricao: curso.descricao, + data: curso.data, + certificadoId: certificadoId as any, + }); + } + notice = { kind: "success", text: "Funcionário cadastrado com sucesso!" }; setTimeout(() => goto("/recursos-humanos/funcionarios"), 600); } catch (e: any) { @@ -327,14 +388,14 @@
    @@ -768,6 +829,121 @@
    + +
    +
    +

    + + + + Cursos e Treinamentos +

    + +

    + Adicione até 7 cursos ou treinamentos realizados pelo funcionário (opcional) +

    + + + {#if cursos.length > 0} +
    +

    Cursos adicionados ({cursos.length}/7)

    + {#each cursos as curso} +
    +
    +

    {curso.descricao}

    +

    {curso.data}

    +
    + +
    + {/each} +
    + {/if} + + + {#if cursos.length < 7} +
    + +
    + Adicionar Curso/Treinamento +
    +
    +
    +
    + + +
    + +
    + + cursoAtual.data = maskDate(e.currentTarget.value)} + /> +
    + +
    + + { + const file = e.currentTarget.files?.[0]; + if (file) cursoAtual.arquivo = file; + }} + /> +
    + + +
    +
    +
    + {:else} +
    + + + + Limite de 7 cursos atingido +
    + {/if} +
    +
    +
    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"} +

    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + {#each coresDisponiveis as cor} + + {/each} +
    +
    +
    + +
    + + +
    +
    +
    + {/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; + }, +}); +