From 367cda7b953217e8c731e5cc125ff5e56b22df2b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Thu, 18 Dec 2025 16:21:08 -0300 Subject: [PATCH] feat: implement almoxarifado features including new category in recursos-humanos, configuration options in TI, and backend support for inventory management, enhancing user navigation and system functionality --- .../components/almoxarifado/AlertaCard.svelte | 89 ++ .../almoxarifado/EstoqueGauge.svelte | 51 + .../almoxarifado/HistoricoTimeline.svelte | 97 ++ .../almoxarifado/MaterialCard.svelte | 68 + .../almoxarifado/MovimentacaoForm.svelte | 219 +++ .../(dashboard)/recursos-humanos/+page.svelte | 57 +- .../almoxarifado/+page.svelte | 196 +++ .../almoxarifado/alertas/+page.svelte | 242 ++++ .../almoxarifado/materiais/+page.svelte | 248 ++++ .../materiais/cadastro/+page.svelte | 319 +++++ .../almoxarifado/movimentacoes/+page.svelte | 550 ++++++++ .../almoxarifado/relatorios/+page.svelte | 313 +++++ .../almoxarifado/requisicoes/+page.svelte | 463 +++++++ .../src/routes/(dashboard)/ti/+page.svelte | 14 + .../configuracoes-almoxarifado/+page.svelte | 357 +++++ packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/almoxarifado.ts | 1179 +++++++++++++++++ .../convex/configuracaoAlmoxarifado.ts | 165 +++ packages/backend/convex/crons.ts | 8 + packages/backend/convex/permissoesAcoes.ts | 43 + packages/backend/convex/schema.ts | 4 +- .../backend/convex/tables/almoxarifado.ts | 149 +++ 22 files changed, 4831 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/components/almoxarifado/AlertaCard.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/MaterialCard.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte create mode 100644 packages/backend/convex/almoxarifado.ts create mode 100644 packages/backend/convex/configuracaoAlmoxarifado.ts create mode 100644 packages/backend/convex/tables/almoxarifado.ts diff --git a/apps/web/src/lib/components/almoxarifado/AlertaCard.svelte b/apps/web/src/lib/components/almoxarifado/AlertaCard.svelte new file mode 100644 index 0000000..2b7d8c3 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/AlertaCard.svelte @@ -0,0 +1,89 @@ + + +
+
+
+
+
+ +

{materialNome}

+
+ {#if materialCodigo} +

Código: {materialCodigo}

+ {/if} +
+ {getTipoLabel(alerta.tipo)} +
+ +
+ +
+
+

Quantidade Atual

+

{alerta.quantidadeAtual}

+
+
+

Quantidade Mínima

+

{alerta.quantidadeMinima}

+
+
+ +
+

Faltam

+

{diferenca} unidades

+
+ +
+ Criado em: {new Date(alerta.criadoEm).toLocaleString('pt-BR')} +
+
+
+ + diff --git a/apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte b/apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte new file mode 100644 index 0000000..09e2dbb --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte @@ -0,0 +1,51 @@ + + +
+
+ Estoque + + {estoqueAtual} {unidadeMedida} + +
+
+
+
+
+ Mín: {estoqueMinimo} + {#if estoqueMaximo} + Máx: {estoqueMaximo} + {/if} +
+
+ + diff --git a/apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte b/apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte new file mode 100644 index 0000000..6e34da9 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte @@ -0,0 +1,97 @@ + + +
+ {#each historico as item, index} +
+ + {#if index < historico.length - 1} +
+
+ +
+
+
+ {:else} +
+ +
+ {/if} + + +
+
+
+
+
+ + {item.usuarioNome || 'Usuário'} +
+ + {getAcaoLabel(item.acao)} + +
+

+ {new Date(item.timestamp).toLocaleString('pt-BR')} +

+ {#if item.observacoes} +
+ +

{item.observacoes}

+
+ {/if} +
+
+
+
+ {/each} +
+ + diff --git a/apps/web/src/lib/components/almoxarifado/MaterialCard.svelte b/apps/web/src/lib/components/almoxarifado/MaterialCard.svelte new file mode 100644 index 0000000..8225960 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/MaterialCard.svelte @@ -0,0 +1,68 @@ + + +
+
+
+
+
+ +

{material.nome}

+
+

Código: {material.codigo}

+ {#if material.descricao} +

{material.descricao}

+ {/if} +
+ {material.categoria} + {#if material.ativo} + Ativo + {:else} + Inativo + {/if} +
+
+
+ +
+ +
+
+

Estoque Atual

+
+

+ {material.estoqueAtual} +

+ {material.unidadeMedida} + {#if material.estoqueAtual <= material.estoqueMinimo} + + {/if} +
+
+
+

Mínimo

+

{material.estoqueMinimo} {material.unidadeMedida}

+
+
+
+
+ + diff --git a/apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte b/apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte new file mode 100644 index 0000000..4ce2fca --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte @@ -0,0 +1,219 @@ + + +
{ e.preventDefault(); handleSubmit(); }}> +
+
+ + +
+ + {#if tipo === 'ajuste'} +
+ + +
+ {:else} +
+ + +
+ {/if} + + {#if tipo === 'entrada'} +
+ + +
+ {:else if tipo === 'saida'} +
+ + +
+
+ + +
+ {/if} + +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index aad3b70..18df4af 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -19,7 +19,10 @@ ArrowRight, Clock, XCircle, - TrendingUp + TrendingUp, + Package, + ArrowLeftRight, + AlertTriangle } from 'lucide-svelte'; import type { Component } from 'svelte'; @@ -155,6 +158,58 @@ Icon: List } ] + }, + { + categoria: 'Almoxarifado', + descricao: 'Controle de estoque e gestão de materiais', + Icon: Package, + gradient: 'from-amber-500/10 to-amber-600/20', + accentColor: 'text-amber-600', + bgIcon: 'bg-amber-500/20', + opcoes: [ + { + nome: 'Dashboard', + descricao: 'Visão geral do almoxarifado', + href: '/recursos-humanos/almoxarifado', + Icon: BarChart3 + }, + { + nome: 'Cadastrar Material', + descricao: 'Adicionar novo material ao estoque', + href: '/recursos-humanos/almoxarifado/materiais/cadastro', + Icon: Plus + }, + { + nome: 'Listar Materiais', + descricao: 'Visualizar e gerenciar materiais', + href: '/recursos-humanos/almoxarifado/materiais', + Icon: Package + }, + { + nome: 'Movimentações', + descricao: 'Registrar entradas e saídas', + href: '/recursos-humanos/almoxarifado/movimentacoes', + Icon: ArrowLeftRight + }, + { + nome: 'Requisições', + descricao: 'Gerenciar requisições de material', + href: '/recursos-humanos/almoxarifado/requisicoes', + Icon: ClipboardList + }, + { + nome: 'Alertas', + descricao: 'Visualizar alertas de estoque baixo', + href: '/recursos-humanos/almoxarifado/alertas', + Icon: AlertTriangle + }, + { + nome: 'Relatórios', + descricao: 'Relatórios e estatísticas', + href: '/recursos-humanos/almoxarifado/relatorios', + Icon: BarChart3 + } + ] } ]; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte new file mode 100644 index 0000000..df710ce --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte @@ -0,0 +1,196 @@ + + +
+ +
+

Almoxarifado

+

+ Controle de estoque e gestão de materiais +

+
+ + + {#if statsQuery.data} +
+
+
+
+ +
+
Total de Materiais
+
+ {statsQuery.data.totalMateriais} +
+
Materiais cadastrados
+
+
+ +
+
+
+ +
+
Materiais Ativos
+
+ {statsQuery.data.totalMateriaisAtivos} +
+
Em estoque
+
+
+ +
+
+
+ +
+
Alertas Ativos
+
+ {statsQuery.data.totalAlertasAtivos} +
+
Estoque baixo
+
+
+ +
+
+
+ +
+
Movimentações
+
+ {statsQuery.data.movimentacoesMes} +
+
Este mês
+
+
+
+ {/if} + + +
+
+
+

+ + Alertas de Estoque +

+ {#if alertasQuery.data && alertasQuery.data.length > 0} +
+ + + + + + + + + + + + {#each alertasQuery.data.slice(0, 5) as alerta} + {@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)} + + + + + + + + {/each} + +
MaterialTipoQuantidade AtualQuantidade MínimaAções
+ {material?.nome || 'Carregando...'} + + {#if alerta.tipo === 'estoque_zerado'} + Zerado + {:else if alerta.tipo === 'estoque_minimo'} + Mínimo + {:else} + Reposição + {/if} + {alerta.quantidadeAtual}{alerta.quantidadeMinima} + +
+
+
+ +
+ {:else} +
+ + Nenhum alerta ativo no momento! +
+ {/if} +
+
+
+ + +
+ + + + + +
+
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte new file mode 100644 index 0000000..fa60428 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte @@ -0,0 +1,242 @@ + + +
+ + + + +
+
+
+ +
+
+

Alertas de Estoque

+

Visualize e gerencie alertas de estoque baixo

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
+
+ + +
+ +
+ + +
+
+
+
+ + +
+
+ {#if alertasQuery.data && alertasQuery.data.length > 0} +
+ + + + + + + + + + + + + + + {#each alertasQuery.data as alerta} + {@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)} + {@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual} + + + + + + + + + + + {/each} + +
MaterialTipoQuantidade AtualQuantidade MínimaDiferençaStatusDataAções
+
{material?.nome || 'Carregando...'}
+
{material?.codigo || ''}
+
+ + {getTipoLabel(alerta.tipo)} + + +
{alerta.quantidadeAtual}
+
+
{alerta.quantidadeMinima}
+
+
-{diferenca}
+
+ {#if alerta.status === 'ativo'} + Ativo + {:else if alerta.status === 'resolvido'} + Resolvido + {:else} + Ignorado + {/if} + {new Date(alerta.criadoEm).toLocaleDateString('pt-BR')} + {#if alerta.status === 'ativo'} +
+ + +
+ {/if} +
+
+ {:else} +
+ +

Nenhum alerta encontrado

+

+ {#if filtroStatus === 'ativo'} + Não há alertas ativos no momento. Todos os materiais estão com estoque adequado! + {:else} + Não há alertas com os filtros selecionados. + {/if} +

+
+ {/if} +
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte new file mode 100644 index 0000000..091843c --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte @@ -0,0 +1,248 @@ + + +
+ + + + +
+
+
+
+ +
+
+

Materiais

+

Gerencie o cadastro de materiais do almoxarifado

+
+
+ +
+
+ + +
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + {#if filtered.length === 0} + + + + {:else} + {#each filtered as material} + + + + + + + + + + + {/each} + {/if} + +
CódigoNomeCategoriaEstoque AtualEstoque MínimoUnidadeStatusAções
+
+ +

Nenhum material encontrado

+
+
+
{material.codigo}
+
+
{material.nome}
+ {#if material.descricao} +
{material.descricao}
+ {/if} +
+ {material.categoria} + +
+ {material.estoqueAtual} + {#if material.estoqueAtual <= material.estoqueMinimo} + + {/if} +
+
{material.estoqueMinimo}{material.unidadeMedida} + {#if material.ativo} + Ativo + {:else} + Inativo + {/if} + +
+ + +
+
+
+ + {#if filtered.length > 0} +
+ Mostrando {filtered.length} de {materiais.length} materiais +
+ {/if} +
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte new file mode 100644 index 0000000..be7f7ca --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte @@ -0,0 +1,319 @@ + + +
+ + + + +
+
+ +
+ +
+
+

Cadastrar Material

+

Adicione um novo material ao almoxarifado

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
{ e.preventDefault(); handleSubmit(); }}> +
+ +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + {#each categoriasComuns as cat} + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte new file mode 100644 index 0000000..fb71cb6 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte @@ -0,0 +1,550 @@ + + +
+ + + + +
+
+
+ +
+
+

Movimentações de Estoque

+

Registre entradas, saídas e ajustes de estoque

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+ + + + +
+ + + {#if abaAtiva === 'entrada'} +
+
+

Registrar Entrada de Material

+
{ e.preventDefault(); registrarEntrada(); }}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'saida'} +
+
+

Registrar Saída de Material

+
{ e.preventDefault(); registrarSaida(); }}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'ajuste'} +
+
+

Ajustar Estoque

+
+ + Ajustes de estoque devem ser justificados e são registrados no histórico. +
+
{ e.preventDefault(); ajustarEstoque(); }}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'historico'} +
+
+

Histórico de Movimentações

+
+ + + + + + + + + + + + + + {#if movimentacoesQuery.data && movimentacoesQuery.data.length > 0} + {#each movimentacoesQuery.data.slice(0, 50) as mov} + {@const material = materiaisQuery.data?.find(m => m._id === mov.materialId)} + + + + + + + + + + {/each} + {:else} + + + + {/if} + +
DataMaterialTipoQuantidadeAnteriorNovaMotivo
{new Date(mov.data).toLocaleString('pt-BR')}{material?.nome || 'Carregando...'} + {#if mov.tipo === 'entrada'} + Entrada + {:else if mov.tipo === 'saida'} + Saída + {:else} + Ajuste + {/if} + {mov.quantidade}{mov.quantidadeAnterior}{mov.quantidadeNova}{mov.motivo}
+
+ +

Nenhuma movimentação registrada

+
+
+
+
+
+ {/if} +
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte new file mode 100644 index 0000000..042a243 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte @@ -0,0 +1,313 @@ + + +
+ + + + +
+
+
+ +
+
+

Relatórios

+

Estatísticas e relatórios do almoxarifado

+
+
+
+ + + {#if statsQuery.data} +
+
+
+
+ +
+
Total de Materiais
+
{statsQuery.data.totalMateriais}
+
Cadastrados no sistema
+
+
+ +
+
+
+ +
+
Materiais Ativos
+
{statsQuery.data.totalMateriaisAtivos}
+
Em estoque
+
+
+ +
+
+
+ +
+
Alertas Ativos
+
{statsQuery.data.totalAlertasAtivos}
+
Estoque baixo
+
+
+ +
+
+
+ +
+
Movimentações
+
{statsQuery.data.movimentacoesMes}
+
Este mês
+
+
+
+ {/if} + + +
+ +
+
+
+

Materiais por Categoria

+ +
+ {#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0} +
+ {#each Object.entries(materiaisPorCategoria) as [categoria, quantidade]} +
+ {categoria} +
+
+
+
+
+
+ {quantidade} +
+
+ {/each} +
+ {:else} +

Nenhum dado disponível

+ {/if} +
+
+ + +
+
+
+

Movimentações do Mês

+ +
+
+
+
+ + Entradas +
+ {movimentacoesMes.entrada} +
+
+
+ + Saídas +
+ {movimentacoesMes.saida} +
+
+
+ + Ajustes +
+ {movimentacoesMes.ajuste} +
+
+
+
+ + +
+
+
+

Materiais com Estoque Baixo

+ +
+ {#if materiaisQuery.data} + {@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)} + {#if estoqueBaixo.length > 0} +
+ + + + + + + + + + {#each estoqueBaixo.slice(0, 10) as material} + + + + + + {/each} + +
MaterialAtualMínimo
+
{material.nome}
+
{material.codigo}
+
+ {material.estoqueAtual} + {material.estoqueMinimo}
+ {#if estoqueBaixo.length > 10} +

+ E mais {estoqueBaixo.length - 10} materiais... +

+ {/if} +
+ {:else} +
+ + Todos os materiais estão com estoque adequado! +
+ {/if} + {/if} +
+
+ + +
+
+
+

Alertas Recentes

+ +
+ {#if alertasQuery.data && alertasQuery.data.length > 0} +
+ {#each alertasQuery.data.slice(0, 5) as alerta} +
+
+
+ {#if materiaisQuery.data} + {@const material = materiaisQuery.data.find(m => m._id === alerta.materialId)} + {material?.nome || 'Carregando...'} + {/if} +
+
+ {alerta.quantidadeAtual} / {alerta.quantidadeMinima} +
+
+ + {#if alerta.tipo === 'estoque_zerado'} + Zerado + {:else} + Mínimo + {/if} + +
+ {/each} +
+ {:else} +
+ + Nenhum alerta ativo +
+ {/if} +
+
+
+
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte new file mode 100644 index 0000000..fea6ff4 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte @@ -0,0 +1,463 @@ + + +
+ + + + +
+
+
+
+ +
+
+

Requisições de Material

+

Gerencie requisições de material dos funcionários

+
+
+ +
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
+ + +
+
+
+ + +
+
+
+ + + + + + + + + + + + + {#if requisicoes.length === 0} + + + + {:else} + {#each requisicoes as requisicao} + {@const solicitante = funcionariosQuery.data?.find(f => f._id === requisicao.solicitanteId)} + {@const setor = setoresQuery.data?.find(s => s._id === requisicao.setorId)} + + + + + + + + + {/each} + {/if} + +
NúmeroSolicitanteSetorStatusDataAções
+
+ +

Nenhuma requisição encontrada

+
+
+
{requisicao.numero}
+
{solicitante?.nome || 'Carregando...'}{setor?.nome || 'Carregando...'} + + {getStatusLabel(requisicao.status)} + + {new Date(requisicao.criadoEm).toLocaleDateString('pt-BR')} +
+ {#if requisicao.status === 'pendente'} + + {:else if requisicao.status === 'aprovada'} + + {/if} +
+
+
+
+
+ + + {#if showModalNova} + + {/if} +
+ + diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 0a9b10b..05d07dd 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -50,6 +50,7 @@ | '/(dashboard)/ti/configuracoes-ponto' | '/(dashboard)/ti/configuracoes-relogio' | '/(dashboard)/ti/configuracoes-jitsi' + | '/(dashboard)/ti/configuracoes-almoxarifado' | '/(dashboard)/configuracoes/setores'; type FeatureCard = { @@ -278,6 +279,19 @@ palette: 'info', icon: 'clock' }, + { + title: 'Configurações de Almoxarifado', + description: + 'Configure parâmetros do sistema de almoxarifado, alertas e regras de estoque. Acesso restrito à TI.', + ctaLabel: 'Configurar Almoxarifado', + href: '/(dashboard)/ti/configuracoes-almoxarifado', + palette: 'warning', + icon: 'control', + highlightBadges: [ + { label: 'Restrito', variant: 'solid' }, + { label: 'TI Only', variant: 'outline' } + ] + }, { title: 'Monitoramento de Emails', description: diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte new file mode 100644 index 0000000..cadc15a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte @@ -0,0 +1,357 @@ + + +
+ + + + +
+
+
+ +
+
+

Configurações de Almoxarifado

+

+ Configure parâmetros do sistema de almoxarifado. Acesso restrito à TI. +

+
+
+
+ + +
+ +
+

Acesso Restrito

+
+ Esta página é restrita apenas para usuários com permissão de TI. Alterações aqui afetam + o comportamento de todo o sistema de almoxarifado. +
+
+
+ + + {#if mensagem} +
+ {mensagem.texto} +
+ {/if} + + +
+
+
{ e.preventDefault(); salvarConfiguracao(); }}> + +
+

Configurações Gerais

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

Requisições

+
+ +
+
+ + +
+ +
+ +
+ + Configure as roles no painel de permissões. Roles com permissão + 'almoxarifado.aprovar_requisicao' podem aprovar requisições. +
+
+
+ + +
+

Alertas e Notificações

+
+ +
+
+ + +
+ + {#if emailAlertasAtivo} +
+ +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + adicionarEmail(); + } + }} + /> + +
+ {#if emailsDestinatarios.length > 0} +
+ {#each emailsDestinatarios as email} +
+ {email} + +
+ {/each} +
+ {:else} +
+ + Nenhum email adicionado +
+ {/if} +
+ {/if} +
+ + +
+

Inventário

+
+ +
+
+ + + +
+
+ + +
+ +
+
+
+
+
+ + diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index f2a86ce..7326c9a 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -62,6 +62,7 @@ import type * as security from "../security.js"; import type * as seed from "../seed.js"; import type * as setores from "../setores.js"; import type * as simbolos from "../simbolos.js"; +import type * as tables_almoxarifado from "../tables/almoxarifado.js"; import type * as tables_atas from "../tables/atas.js"; import type * as tables_atestados from "../tables/atestados.js"; import type * as tables_ausencias from "../tables/ausencias.js"; @@ -156,6 +157,7 @@ declare const fullApi: ApiFromModules<{ seed: typeof seed; setores: typeof setores; simbolos: typeof simbolos; + "tables/almoxarifado": typeof tables_almoxarifado; "tables/atas": typeof tables_atas; "tables/atestados": typeof tables_atestados; "tables/ausencias": typeof tables_ausencias; diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts new file mode 100644 index 0000000..c3d292d --- /dev/null +++ b/packages/backend/convex/almoxarifado.ts @@ -0,0 +1,1179 @@ +import { v } from 'convex/values'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { internal, internalMutation, internalAction, mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import { + alertaStatus, + alertaTipo, + movimentacaoTipo, + requisicaoStatus +} from './tables/almoxarifado'; + +// ========== QUERIES ========== + +export const listarMateriais = query({ + args: { + categoria: v.optional(v.string()), + ativo: v.optional(v.boolean()), + estoqueBaixo: v.optional(v.boolean()), + busca: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('materiais'); + + if (args.ativo !== undefined) { + query = query.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo)); + } else if (args.categoria) { + query = query.withIndex('by_categoria', (q) => q.eq('categoria', args.categoria)); + } else { + query = query; + } + + let materiais = await query.collect(); + + // Filtros adicionais + if (args.busca) { + const buscaLower = args.busca.toLowerCase(); + materiais = materiais.filter( + (m) => + m.codigo.toLowerCase().includes(buscaLower) || + m.nome.toLowerCase().includes(buscaLower) + ); + } + + if (args.estoqueBaixo) { + materiais = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); + } + + return materiais; + } +}); + +export const obterMaterial = query({ + args: { id: v.id('materiais') }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const material = await ctx.db.get(args.id); + if (!material) throw new Error('Material não encontrado'); + return material; + } +}); + +export const listarMovimentacoes = query({ + args: { + materialId: v.optional(v.id('materiais')), + tipo: v.optional(movimentacaoTipo), + dataInicio: v.optional(v.number()), + dataFim: v.optional(v.number()), + funcionarioId: v.optional(v.id('funcionarios')) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('movimentacoesEstoque'); + + if (args.materialId) { + query = query.withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); + } else if (args.tipo) { + query = query.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo)); + } else if (args.funcionarioId) { + query = query.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId)); + } else { + query = query.withIndex('by_data'); + } + + let movimentacoes = await query.collect(); + + // Filtros de data + if (args.dataInicio) { + movimentacoes = movimentacoes.filter((m) => m.data >= args.dataInicio!); + } + if (args.dataFim) { + movimentacoes = movimentacoes.filter((m) => m.data <= args.dataFim!); + } + + // Ordenar por data (mais recente primeiro) + movimentacoes.sort((a, b) => b.data - a.data); + + return movimentacoes; + } +}); + +export const listarRequisicoes = query({ + args: { + status: v.optional(requisicaoStatus), + solicitanteId: v.optional(v.id('funcionarios')), + setorId: v.optional(v.id('setores')) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('requisicoesMaterial'); + + if (args.status) { + query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + } else if (args.solicitanteId) { + query = query.withIndex('by_solicitanteId', (q) => q.eq('solicitanteId', args.solicitanteId)); + } else if (args.setorId) { + query = query.withIndex('by_setorId', (q) => q.eq('setorId', args.setorId)); + } + + const requisicoes = await query.collect(); + + // Ordenar por data de criação (mais recente primeiro) + requisicoes.sort((a, b) => b.criadoEm - a.criadoEm); + + return requisicoes; + } +}); + +export const obterRequisicao = query({ + args: { id: v.id('requisicoesMaterial') }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + + // Buscar itens da requisição + const itens = await ctx.db + .query('requisicaoItens') + .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) + .collect(); + + return { + ...requisicao, + itens + }; + } +}); + +export const listarAlertas = query({ + args: { + status: v.optional(alertaStatus), + tipo: v.optional(alertaTipo) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('alertasEstoque'); + + if (args.status) { + query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + } else if (args.tipo) { + query = query.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo)); + } + + const alertas = await query.collect(); + + // Ordenar por data de criação (mais recente primeiro) + alertas.sort((a, b) => b.criadoEm - a.criadoEm); + + return alertas; + } +}); + +export const obterHistorico = query({ + args: { + tipoEntidade: v.string(), + entidadeId: v.string() + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const historico = await ctx.db + .query('historicoAlteracoes') + .withIndex('by_tipoEntidade', (q) => q.eq('tipoEntidade', args.tipoEntidade)) + .filter((q) => q.eq(q.field('entidadeId'), args.entidadeId)) + .collect(); + + // Ordenar por timestamp (mais recente primeiro) + historico.sort((a, b) => b.timestamp - a.timestamp); + + return historico; + } +}); + +export const obterEstatisticas = query({ + args: {}, + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + return { + totalMateriais: 0, + totalMateriaisAtivos: 0, + totalAlertasAtivos: 0, + movimentacoesMes: 0, + materiaisEstoqueBaixo: 0 + }; + } + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return { + totalMateriais: 0, + totalMateriaisAtivos: 0, + totalAlertasAtivos: 0, + movimentacoesMes: 0, + materiaisEstoqueBaixo: 0 + }; + } + + const materiais = await ctx.db.query('materiais').collect(); + const materiaisAtivos = materiais.filter((m) => m.ativo); + const materiaisEstoqueBaixo = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); + + const alertasAtivos = await ctx.db + .query('alertasEstoque') + .withIndex('by_status', (q) => q.eq('status', 'ativo')) + .collect(); + + const agora = Date.now(); + const inicioMes = new Date(agora); + inicioMes.setDate(1); + inicioMes.setHours(0, 0, 0, 0); + + const movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_data') + .collect(); + + const movimentacoesMes = movimentacoes.filter((m) => m.data >= inicioMes.getTime()).length; + + return { + totalMateriais: materiais.length, + totalMateriaisAtivos: materiaisAtivos.length, + totalAlertasAtivos: alertasAtivos.length, + movimentacoesMes, + materiaisEstoqueBaixo: materiaisEstoqueBaixo.length + }; + } +}); + +export const verificarEstoqueBaixo = query({ + args: {}, + handler: async (ctx) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const materiais = await ctx.db + .query('materiais') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + + return materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); + } +}); + +// ========== MUTATIONS ========== + +async function registrarHistorico( + ctx: MutationCtx, + tipoEntidade: string, + entidadeId: string, + acao: string, + dadosAnteriores?: Record, + dadosNovos?: Record, + observacoes?: string +) { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return; + + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade, + entidadeId, + acao, + usuarioId: usuario._id, + dadosAnteriores: dadosAnteriores ? JSON.stringify(dadosAnteriores) : undefined, + dadosNovos: dadosNovos ? JSON.stringify(dadosNovos) : undefined, + timestamp: Date.now(), + observacoes + }); +} + +async function verificarECriarAlerta(ctx: MutationCtx, materialId: Id<'materiais'>) { + const material = await ctx.db.get(materialId); + if (!material || !material.ativo) return; + + // Verificar se já existe alerta ativo para este material + const alertasExistentes = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', materialId)) + .filter((q) => q.eq(q.field('status'), 'ativo')) + .collect(); + + if (alertasExistentes.length > 0) { + // Já existe alerta ativo + return; + } + + // Determinar tipo de alerta + let tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria' = 'estoque_minimo'; + if (material.estoqueAtual === 0) { + tipo = 'estoque_zerado'; + } else if (material.estoqueAtual < material.estoqueMinimo) { + tipo = 'estoque_minimo'; + } + + // Criar alerta + await ctx.db.insert('alertasEstoque', { + materialId, + tipo, + quantidadeAtual: material.estoqueAtual, + quantidadeMinima: material.estoqueMinimo, + status: 'ativo', + criadoEm: Date.now() + }); +} + +async function resolverAlertasMaterial(ctx: MutationCtx, materialId: Id<'materiais'>) { + const alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', materialId)) + .filter((q) => q.eq(q.field('status'), 'ativo')) + .collect(); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return; + + for (const alerta of alertas) { + await ctx.db.patch(alerta._id, { + status: 'resolvido', + resolvidoEm: Date.now(), + resolvidoPor: usuario._id + }); + } +} + +export const criarMaterial = mutation({ + args: { + codigo: v.string(), + nome: v.string(), + descricao: v.optional(v.string()), + categoria: v.string(), + unidadeMedida: v.string(), + estoqueMinimo: v.number(), + estoqueMaximo: v.optional(v.number()), + estoqueAtual: v.optional(v.number()), + localizacao: v.optional(v.string()), + fornecedor: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'criar_material' + }); + + // Verificar se código já existe + const codigoExistente = await ctx.db + .query('materiais') + .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) + .unique(); + + if (codigoExistente) { + throw new Error('Código do material já existe'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const agora = Date.now(); + const materialId = await ctx.db.insert('materiais', { + ...args, + estoqueAtual: args.estoqueAtual ?? 0, + ativo: true, + criadoPor: usuario._id, + criadoEm: agora, + atualizadoEm: agora + }); + + // Registrar histórico + await registrarHistorico(ctx, 'material', materialId.toString(), 'criacao', undefined, args as Record); + + // Verificar se precisa criar alerta + if (args.estoqueAtual !== undefined && args.estoqueAtual <= args.estoqueMinimo) { + await verificarECriarAlerta(ctx, materialId); + } + + return materialId; + } +}); + +export const editarMaterial = mutation({ + args: { + id: v.id('materiais'), + codigo: v.optional(v.string()), + nome: v.optional(v.string()), + descricao: v.optional(v.string()), + categoria: v.optional(v.string()), + unidadeMedida: v.optional(v.string()), + estoqueMinimo: v.optional(v.number()), + estoqueMaximo: v.optional(v.number()), + localizacao: v.optional(v.string()), + fornecedor: v.optional(v.string()), + ativo: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'editar_material' + }); + + const material = await ctx.db.get(args.id); + if (!material) throw new Error('Material não encontrado'); + + // Verificar se código já existe (se foi alterado) + if (args.codigo && args.codigo !== material.codigo) { + const codigoExistente = await ctx.db + .query('materiais') + .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) + .unique(); + + if (codigoExistente) { + throw new Error('Código do material já existe'); + } + } + + const dadosAnteriores = { ...material }; + const dadosNovos: Partial> & { atualizadoEm: number } = { + atualizadoEm: Date.now() + }; + + // Atualizar apenas campos fornecidos + if (args.codigo !== undefined) dadosNovos.codigo = args.codigo; + if (args.nome !== undefined) dadosNovos.nome = args.nome; + if (args.descricao !== undefined) dadosNovos.descricao = args.descricao; + if (args.categoria !== undefined) dadosNovos.categoria = args.categoria; + if (args.unidadeMedida !== undefined) dadosNovos.unidadeMedida = args.unidadeMedida; + if (args.estoqueMinimo !== undefined) dadosNovos.estoqueMinimo = args.estoqueMinimo; + if (args.estoqueMaximo !== undefined) dadosNovos.estoqueMaximo = args.estoqueMaximo; + if (args.localizacao !== undefined) dadosNovos.localizacao = args.localizacao; + if (args.fornecedor !== undefined) dadosNovos.fornecedor = args.fornecedor; + if (args.ativo !== undefined) dadosNovos.ativo = args.ativo; + + await ctx.db.patch(args.id, dadosNovos); + + // Registrar histórico + await registrarHistorico(ctx, 'material', args.id.toString(), 'edicao', dadosAnteriores, dadosNovos); + + // Verificar se precisa criar/resolver alertas + if (args.estoqueMinimo !== undefined || args.ativo !== undefined) { + const materialAtualizado = await ctx.db.get(args.id); + if (materialAtualizado) { + if (materialAtualizado.ativo && materialAtualizado.estoqueAtual <= materialAtualizado.estoqueMinimo) { + await verificarECriarAlerta(ctx, args.id); + } else if (materialAtualizado.estoqueAtual > materialAtualizado.estoqueMinimo) { + await resolverAlertasMaterial(ctx, args.id); + } + } + } + } +}); + +export const registrarEntrada = mutation({ + args: { + materialId: v.id('materiais'), + quantidade: v.number(), + motivo: v.string(), + documento: v.optional(v.string()), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'registrar_movimentacao' + }); + + if (args.quantidade <= 0) { + throw new Error('Quantidade deve ser maior que zero'); + } + + const material = await ctx.db.get(args.materialId); + if (!material) throw new Error('Material não encontrado'); + if (!material.ativo) throw new Error('Material está inativo'); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const quantidadeAnterior = material.estoqueAtual; + const quantidadeNova = quantidadeAnterior + args.quantidade; + + // Atualizar estoque + await ctx.db.patch(args.materialId, { + estoqueAtual: quantidadeNova, + atualizadoEm: Date.now() + }); + + // Registrar movimentação + const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { + materialId: args.materialId, + tipo: 'entrada', + quantidade: args.quantidade, + quantidadeAnterior, + quantidadeNova, + motivo: args.motivo, + documento: args.documento, + usuarioId: usuario._id, + data: Date.now(), + observacoes: args.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { + tipo: 'entrada', + materialId: args.materialId, + quantidade: args.quantidade + }); + + // Verificar se precisa resolver alertas + if (quantidadeNova > material.estoqueMinimo) { + await resolverAlertasMaterial(ctx, args.materialId); + } + + return movimentacaoId; + } +}); + +export const registrarSaida = mutation({ + args: { + materialId: v.id('materiais'), + quantidade: v.number(), + motivo: v.string(), + funcionarioId: v.optional(v.id('funcionarios')), + setorId: v.optional(v.id('setores')), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'registrar_movimentacao' + }); + + if (args.quantidade <= 0) { + throw new Error('Quantidade deve ser maior que zero'); + } + + const material = await ctx.db.get(args.materialId); + if (!material) throw new Error('Material não encontrado'); + if (!material.ativo) throw new Error('Material está inativo'); + + // Verificar configuração de estoque negativo + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + if (!config?.permitirEstoqueNegativo && material.estoqueAtual < args.quantidade) { + throw new Error('Estoque insuficiente'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const quantidadeAnterior = material.estoqueAtual; + const quantidadeNova = Math.max(0, quantidadeAnterior - args.quantidade); + + // Atualizar estoque + await ctx.db.patch(args.materialId, { + estoqueAtual: quantidadeNova, + atualizadoEm: Date.now() + }); + + // Registrar movimentação + const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { + materialId: args.materialId, + tipo: 'saida', + quantidade: args.quantidade, + quantidadeAnterior, + quantidadeNova, + motivo: args.motivo, + funcionarioId: args.funcionarioId, + setorId: args.setorId, + usuarioId: usuario._id, + data: Date.now(), + observacoes: args.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { + tipo: 'saida', + materialId: args.materialId, + quantidade: args.quantidade + }); + + // Verificar se precisa criar alerta + if (quantidadeNova <= material.estoqueMinimo) { + await verificarECriarAlerta(ctx, args.materialId); + } + + return movimentacaoId; + } +}); + +export const ajustarEstoque = mutation({ + args: { + materialId: v.id('materiais'), + quantidadeNova: v.number(), + motivo: v.string(), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'ajustar_estoque' + }); + + if (args.quantidadeNova < 0) { + throw new Error('Quantidade não pode ser negativa'); + } + + const material = await ctx.db.get(args.materialId); + if (!material) throw new Error('Material não encontrado'); + if (!material.ativo) throw new Error('Material está inativo'); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const quantidadeAnterior = material.estoqueAtual; + const diferenca = args.quantidadeNova - quantidadeAnterior; + + // Atualizar estoque + await ctx.db.patch(args.materialId, { + estoqueAtual: args.quantidadeNova, + atualizadoEm: Date.now() + }); + + // Registrar movimentação + const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { + materialId: args.materialId, + tipo: 'ajuste', + quantidade: Math.abs(diferenca), + quantidadeAnterior, + quantidadeNova: args.quantidadeNova, + motivo: args.motivo, + usuarioId: usuario._id, + data: Date.now(), + observacoes: args.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { + tipo: 'ajuste', + materialId: args.materialId, + quantidadeAnterior, + quantidadeNova: args.quantidadeNova + }); + + // Verificar se precisa criar/resolver alertas + if (args.quantidadeNova <= material.estoqueMinimo) { + await verificarECriarAlerta(ctx, args.materialId); + } else { + await resolverAlertasMaterial(ctx, args.materialId); + } + + return movimentacaoId; + } +}); + +export const criarRequisicao = mutation({ + args: { + solicitanteId: v.id('funcionarios'), + setorId: v.id('setores'), + itens: v.array( + v.object({ + materialId: v.id('materiais'), + quantidadeSolicitada: v.number(), + observacoes: v.optional(v.string()) + }) + ), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + if (args.itens.length === 0) { + throw new Error('Requisição deve ter pelo menos um item'); + } + + // Gerar número sequencial da requisição + const todasRequisicoes = await ctx.db.query('requisicoesMaterial').collect(); + const proximoNumero = (todasRequisicoes.length + 1).toString().padStart(6, '0'); + const numero = `REQ-${proximoNumero}`; + + const agora = Date.now(); + const requisicaoId = await ctx.db.insert('requisicoesMaterial', { + numero, + solicitanteId: args.solicitanteId, + setorId: args.setorId, + status: 'pendente', + observacoes: args.observacoes, + criadoEm: agora, + atualizadoEm: agora + }); + + // Criar itens da requisição + for (const item of args.itens) { + await ctx.db.insert('requisicaoItens', { + requisicaoId, + materialId: item.materialId, + quantidadeSolicitada: item.quantidadeSolicitada, + observacoes: item.observacoes + }); + } + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', requisicaoId.toString(), 'criacao', undefined, { + numero, + solicitanteId: args.solicitanteId, + itens: args.itens + }); + + return requisicaoId; + } +}); + +export const aprovarRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial'), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'aprovar_requisicao' + }); + + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + if (requisicao.status !== 'pendente') { + throw new Error('Apenas requisições pendentes podem ser aprovadas'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Buscar funcionário do usuário + const funcionario = await ctx.db + .query('funcionarios') + .filter((q) => q.eq(q.field('email'), usuario.email)) + .first(); + + if (!funcionario) throw new Error('Funcionário não encontrado para o usuário'); + + await ctx.db.patch(args.id, { + status: 'aprovada', + aprovadoPor: funcionario._id, + dataAprovacao: Date.now(), + atualizadoEm: Date.now(), + observacoes: args.observacoes || requisicao.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'aprovada', + aprovadoPor: funcionario._id + }); + } +}); + +export const atenderRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial') + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'registrar_movimentacao' + }); + + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + if (requisicao.status !== 'aprovada') { + throw new Error('Apenas requisições aprovadas podem ser atendidas'); + } + + // Buscar itens da requisição + const itens = await ctx.db + .query('requisicaoItens') + .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) + .collect(); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Processar cada item + for (const item of itens) { + const material = await ctx.db.get(item.materialId); + if (!material) continue; + + const quantidadeAtendida = Math.min(item.quantidadeSolicitada, material.estoqueAtual); + + // Atualizar item com quantidade atendida + await ctx.db.patch(item._id, { + quantidadeAtendida + }); + + if (quantidadeAtendida > 0) { + // Registrar saída + const quantidadeAnterior = material.estoqueAtual; + const quantidadeNova = quantidadeAnterior - quantidadeAtendida; + + await ctx.db.patch(item.materialId, { + estoqueAtual: quantidadeNova, + atualizadoEm: Date.now() + }); + + await ctx.db.insert('movimentacoesEstoque', { + materialId: item.materialId, + tipo: 'saida', + quantidade: quantidadeAtendida, + quantidadeAnterior, + quantidadeNova, + motivo: `Atendimento da requisição ${requisicao.numero}`, + funcionarioId: requisicao.solicitanteId, + setorId: requisicao.setorId, + usuarioId: usuario._id, + data: Date.now(), + observacoes: `Requisição ${requisicao.numero}` + }); + + // Verificar alertas + if (quantidadeNova <= material.estoqueMinimo) { + await verificarECriarAlerta(ctx, item.materialId); + } + } + } + + // Atualizar status da requisição + await ctx.db.patch(args.id, { + status: 'atendida', + atualizadoEm: Date.now() + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'atendida' + }); + } +}); + +export const cancelarRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial'), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + if (requisicao.status === 'atendida' || requisicao.status === 'cancelada') { + throw new Error('Requisição já foi atendida ou cancelada'); + } + + await ctx.db.patch(args.id, { + status: 'cancelada', + atualizadoEm: Date.now(), + observacoes: args.observacoes || requisicao.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'cancelada' + }); + } +}); + +export const resolverAlerta = mutation({ + args: { + id: v.id('alertasEstoque') + }, + handler: async (ctx, args) => { + const alerta = await ctx.db.get(args.id); + if (!alerta) throw new Error('Alerta não encontrado'); + if (alerta.status !== 'ativo') { + throw new Error('Apenas alertas ativos podem ser resolvidos'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + await ctx.db.patch(args.id, { + status: 'resolvido', + resolvidoEm: Date.now(), + resolvidoPor: usuario._id + }); + + // Registrar histórico + await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { + status: 'resolvido' + }); + } +}); + +export const ignorarAlerta = mutation({ + args: { + id: v.id('alertasEstoque') + }, + handler: async (ctx, args) => { + const alerta = await ctx.db.get(args.id); + if (!alerta) throw new Error('Alerta não encontrado'); + if (alerta.status !== 'ativo') { + throw new Error('Apenas alertas ativos podem ser ignorados'); + } + + await ctx.db.patch(args.id, { + status: 'ignorado' + }); + + // Registrar histórico + await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { + status: 'ignorado' + }); + } +}); + +// ========== INTERNAL ACTIONS ========== + +export const registrarHistoricoAlteracao = internalMutation({ + args: { + tipoEntidade: v.string(), + entidadeId: v.string(), + acao: v.string(), + dadosAnteriores: v.optional(v.string()), + dadosNovos: v.optional(v.string()), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return; + + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade: args.tipoEntidade, + entidadeId: args.entidadeId, + acao: args.acao, + usuarioId: usuario._id, + dadosAnteriores: args.dadosAnteriores, + dadosNovos: args.dadosNovos, + timestamp: Date.now(), + observacoes: args.observacoes + }); + } +}); + +export const verificarAlertasAutomatico = internalAction({ + args: {}, + handler: async (ctx) => { + // Buscar todos os materiais ativos + const materiais = await ctx.runQuery(internal.almoxarifado.listarMateriaisInterno, { + ativo: true + }); + + // Buscar configuração + const config = await ctx.runQuery(internal.configuracaoAlmoxarifado.obterConfiguracaoInterno, {}); + + for (const material of materiais) { + // Verificar se precisa criar alerta + if (material.estoqueAtual <= material.estoqueMinimo) { + // Verificar se já existe alerta ativo + const alertasExistentes = await ctx.runQuery( + internal.almoxarifado.listarAlertasPorMaterial, + { + materialId: material._id, + status: 'ativo' as const + } + ); + + if (alertasExistentes.length === 0) { + // Criar novo alerta + let tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria' = + 'estoque_minimo'; + if (material.estoqueAtual === 0) { + tipo = 'estoque_zerado'; + } + + await ctx.runMutation(internal.almoxarifado.criarAlertaInterno, { + materialId: material._id, + tipo, + quantidadeAtual: material.estoqueAtual, + quantidadeMinima: material.estoqueMinimo + }); + } + } else { + // Resolver alertas se estoque está acima do mínimo + await ctx.runMutation(internal.almoxarifado.resolverAlertasMaterialInterno, { + materialId: material._id + }); + } + } + + // Enviar notificações se configurado + if (config?.emailAlertasAtivo) { + await ctx.runAction(internal.almoxarifado.enviarNotificacoesAlerta, {}); + } + } +}); + +export const enviarNotificacoesAlerta = internalAction({ + args: {}, + handler: async (ctx) => { + // Buscar alertas ativos + const alertas = await ctx.runQuery(internal.almoxarifado.listarAlertasInterno, { + status: 'ativo' + }); + + if (alertas.length === 0) return; + + // Buscar configuração para obter emails + const config = await ctx.runQuery(internal.configuracaoAlmoxarifado.obterConfiguracaoInterno, {}); + + if (!config?.emailAlertasAtivo || config.emailsDestinatarios.length === 0) { + return; + } + + // Aqui você pode integrar com o sistema de email do projeto + // Por enquanto, apenas logamos + console.log(`Enviando notificações de ${alertas.length} alertas para:`, config.emailsDestinatarios); + } +}); + +// ========== INTERNAL QUERIES ========== + +export const listarMateriaisInterno = internalQuery({ + args: { + ativo: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + let query = ctx.db.query('materiais'); + if (args.ativo !== undefined) { + query = query.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo)); + } + return await query.collect(); + } +}); + +export const listarAlertasPorMaterial = internalQuery({ + args: { + materialId: v.id('materiais'), + status: v.optional(alertaStatus) + }, + handler: async (ctx, args) => { + let query = ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); + if (args.status) { + query = query.filter((q) => q.eq(q.field('status'), args.status)); + } + return await query.collect(); + } +}); + +export const listarAlertasInterno = internalQuery({ + args: { + status: v.optional(alertaStatus) + }, + handler: async (ctx, args) => { + let query = ctx.db.query('alertasEstoque'); + if (args.status) { + query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + } + return await query.collect(); + } +}); + +// ========== INTERNAL MUTATIONS ========== + +export const criarAlertaInterno = internalMutation({ + args: { + materialId: v.id('materiais'), + tipo: alertaTipo, + quantidadeAtual: v.number(), + quantidadeMinima: v.number() + }, + handler: async (ctx, args) => { + await ctx.db.insert('alertasEstoque', { + materialId: args.materialId, + tipo: args.tipo, + quantidadeAtual: args.quantidadeAtual, + quantidadeMinima: args.quantidadeMinima, + status: 'ativo', + criadoEm: Date.now() + }); + } +}); + +export const resolverAlertasMaterialInterno = internalMutation({ + args: { + materialId: v.id('materiais') + }, + handler: async (ctx, args) => { + const alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)) + .filter((q) => q.eq(q.field('status'), 'ativo')) + .collect(); + + for (const alerta of alertas) { + await ctx.db.patch(alerta._id, { + status: 'resolvido', + resolvidoEm: Date.now() + }); + } + } +}); + diff --git a/packages/backend/convex/configuracaoAlmoxarifado.ts b/packages/backend/convex/configuracaoAlmoxarifado.ts new file mode 100644 index 0000000..6726539 --- /dev/null +++ b/packages/backend/convex/configuracaoAlmoxarifado.ts @@ -0,0 +1,165 @@ +import { v } from 'convex/values'; +import { internal, internalQuery, mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; + +export const obterConfiguracao = query({ + args: {}, + handler: async (ctx) => { + // Verificar se usuário tem permissão de TI + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'configurar' + }); + + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + // Se não existe configuração, retornar valores padrão + if (!config) { + return { + estoqueMinimoPadrao: 10, + diasAntecedenciaAlerta: 7, + permitirEstoqueNegativo: false, + requerAprovacaoRequisicao: true, + rolesAprovacao: [], + emailAlertasAtivo: false, + emailsDestinatarios: [], + periodicidadeInventario: 30, + ultimoInventario: undefined, + ativo: true + }; + } + + return config; + } +}); + +export const atualizarConfiguracao = mutation({ + args: { + estoqueMinimoPadrao: v.optional(v.number()), + diasAntecedenciaAlerta: v.optional(v.number()), + permitirEstoqueNegativo: v.optional(v.boolean()), + requerAprovacaoRequisicao: v.optional(v.boolean()), + rolesAprovacao: v.optional(v.array(v.string())), + emailAlertasAtivo: v.optional(v.boolean()), + emailsDestinatarios: v.optional(v.array(v.string())), + periodicidadeInventario: v.optional(v.number()), + ultimoInventario: v.optional(v.number()) + }, + handler: async (ctx, args) => { + // Verificar se usuário tem permissão de TI_MASTER + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'configurar' + }); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Buscar configuração existente + let config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + const dadosAnteriores = config ? { ...config } : undefined; + + if (config) { + // Desativar configuração antiga + await ctx.db.patch(config._id, { ativo: false }); + + // Criar nova configuração + const dadosNovos = { + ...config, + ...args, + ativo: true, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }; + + const novaConfigId = await ctx.db.insert('configuracoesAlmoxarifado', dadosNovos); + + // Registrar histórico + if (usuario) { + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade: 'configuracao', + entidadeId: novaConfigId, + acao: 'edicao', + usuarioId: usuario._id, + dadosAnteriores: dadosAnteriores ? JSON.stringify(dadosAnteriores) : undefined, + dadosNovos: JSON.stringify(dadosNovos), + timestamp: Date.now(), + observacoes: 'Atualização de configurações do almoxarifado' + }); + } + + return novaConfigId; + } else { + // Criar primeira configuração + const dadosNovos = { + estoqueMinimoPadrao: args.estoqueMinimoPadrao ?? 10, + diasAntecedenciaAlerta: args.diasAntecedenciaAlerta ?? 7, + permitirEstoqueNegativo: args.permitirEstoqueNegativo ?? false, + requerAprovacaoRequisicao: args.requerAprovacaoRequisicao ?? true, + rolesAprovacao: args.rolesAprovacao ?? [], + emailAlertasAtivo: args.emailAlertasAtivo ?? false, + emailsDestinatarios: args.emailsDestinatarios ?? [], + periodicidadeInventario: args.periodicidadeInventario ?? 30, + ultimoInventario: args.ultimoInventario, + ativo: true, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }; + + const novaConfigId = await ctx.db.insert('configuracoesAlmoxarifado', dadosNovos); + + // Registrar histórico + if (usuario) { + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade: 'configuracao', + entidadeId: novaConfigId, + acao: 'criacao', + usuarioId: usuario._id, + dadosAnteriores: undefined, + dadosNovos: JSON.stringify(dadosNovos), + timestamp: Date.now(), + observacoes: 'Criação de configurações do almoxarifado' + }); + } + + return novaConfigId; + } + } +}); + +// ========== INTERNAL QUERIES ========== + +export const obterConfiguracaoInterno = internalQuery({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + if (!config) { + return { + estoqueMinimoPadrao: 10, + diasAntecedenciaAlerta: 7, + permitirEstoqueNegativo: false, + requerAprovacaoRequisicao: true, + rolesAprovacao: [], + emailAlertasAtivo: false, + emailsDestinatarios: [], + periodicidadeInventario: 30, + ultimoInventario: undefined, + ativo: true + }; + } + + return config; + } +}); + diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index 3ef6845..03b83a9 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -58,4 +58,12 @@ crons.interval( {} ); +// Verificar alertas de estoque do almoxarifado diariamente +crons.interval( + 'verificar-alertas-almoxarifado', + { hours: 24 }, + internal.almoxarifado.verificarAlertasAutomatico, + {} +); + export default crons; diff --git a/packages/backend/convex/permissoesAcoes.ts b/packages/backend/convex/permissoesAcoes.ts index d2f1f21..0d5ed12 100644 --- a/packages/backend/convex/permissoesAcoes.ts +++ b/packages/backend/convex/permissoesAcoes.ts @@ -735,6 +735,49 @@ const PERMISSOES_BASE = { recurso: 'config', acao: 'gerenciar_compras', descricao: 'Gerenciar configurações de compras' + }, + // Almoxarifado + { + nome: 'almoxarifado.listar', + recurso: 'almoxarifado', + acao: 'listar', + descricao: 'Listar materiais e movimentações' + }, + { + nome: 'almoxarifado.criar_material', + recurso: 'almoxarifado', + acao: 'criar_material', + descricao: 'Cadastrar novos materiais' + }, + { + nome: 'almoxarifado.editar_material', + recurso: 'almoxarifado', + acao: 'editar_material', + descricao: 'Editar materiais existentes' + }, + { + nome: 'almoxarifado.registrar_movimentacao', + recurso: 'almoxarifado', + acao: 'registrar_movimentacao', + descricao: 'Registrar entradas e saídas' + }, + { + nome: 'almoxarifado.ajustar_estoque', + recurso: 'almoxarifado', + acao: 'ajustar_estoque', + descricao: 'Realizar ajustes manuais de estoque' + }, + { + nome: 'almoxarifado.aprovar_requisicao', + recurso: 'almoxarifado', + acao: 'aprovar_requisicao', + descricao: 'Aprovar requisições de material' + }, + { + nome: 'almoxarifado.configurar', + recurso: 'almoxarifado', + acao: 'configurar', + descricao: 'Configurar sistema de almoxarifado (apenas TI)' } ] } as const; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 2dec986..8bfc339 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -22,6 +22,7 @@ import { systemTables } from './tables/system'; import { ticketsTables } from './tables/tickets'; import { timesTables } from './tables/times'; import { lgpdTables } from './tables/lgpdTables'; +import { almoxarifadoTables } from './tables/almoxarifado'; export default defineSchema({ ...setoresTables, @@ -46,5 +47,6 @@ export default defineSchema({ ...planejamentosTables, ...objetosTables, ...atasTables, - ...lgpdTables + ...lgpdTables, + ...almoxarifadoTables }); diff --git a/packages/backend/convex/tables/almoxarifado.ts b/packages/backend/convex/tables/almoxarifado.ts new file mode 100644 index 0000000..75c5c64 --- /dev/null +++ b/packages/backend/convex/tables/almoxarifado.ts @@ -0,0 +1,149 @@ +import { defineTable } from 'convex/server'; +import { type Infer, v } from 'convex/values'; + +export const movimentacaoTipo = v.union( + v.literal('entrada'), + v.literal('saida'), + v.literal('ajuste'), + v.literal('transferencia') +); +export type MovimentacaoTipo = Infer; + +export const requisicaoStatus = v.union( + v.literal('pendente'), + v.literal('aprovada'), + v.literal('rejeitada'), + v.literal('atendida'), + v.literal('cancelada') +); +export type RequisicaoStatus = Infer; + +export const alertaTipo = v.union( + v.literal('estoque_minimo'), + v.literal('estoque_zerado'), + v.literal('reposicao_necessaria') +); +export type AlertaTipo = Infer; + +export const alertaStatus = v.union( + v.literal('ativo'), + v.literal('resolvido'), + v.literal('ignorado') +); +export type AlertaStatus = Infer; + +export const almoxarifadoTables = { + materiais: defineTable({ + codigo: v.string(), + nome: v.string(), + descricao: v.optional(v.string()), + categoria: v.string(), + unidadeMedida: v.string(), + estoqueMinimo: v.number(), + estoqueMaximo: v.optional(v.number()), + estoqueAtual: v.number(), + localizacao: v.optional(v.string()), + fornecedor: v.optional(v.string()), + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_codigo', ['codigo']) + .index('by_categoria', ['categoria']) + .index('by_ativo', ['ativo']) + .index('by_estoqueAtual', ['estoqueAtual']), + + movimentacoesEstoque: defineTable({ + materialId: v.id('materiais'), + tipo: movimentacaoTipo, + quantidade: v.number(), + quantidadeAnterior: v.number(), + quantidadeNova: v.number(), + motivo: v.string(), + documento: v.optional(v.string()), + funcionarioId: v.optional(v.id('funcionarios')), + setorId: v.optional(v.id('setores')), + usuarioId: v.id('usuarios'), + data: v.number(), + observacoes: v.optional(v.string()) + }) + .index('by_materialId', ['materialId']) + .index('by_tipo', ['tipo']) + .index('by_data', ['data']) + .index('by_funcionarioId', ['funcionarioId']) + .index('by_usuarioId', ['usuarioId']), + + requisicoesMaterial: defineTable({ + numero: v.string(), + solicitanteId: v.id('funcionarios'), + setorId: v.id('setores'), + status: requisicaoStatus, + aprovadoPor: v.optional(v.id('funcionarios')), + dataAprovacao: v.optional(v.number()), + observacoes: v.optional(v.string()), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_status', ['status']) + .index('by_solicitanteId', ['solicitanteId']) + .index('by_setorId', ['setorId']) + .index('by_numero', ['numero']), + + requisicaoItens: defineTable({ + requisicaoId: v.id('requisicoesMaterial'), + materialId: v.id('materiais'), + quantidadeSolicitada: v.number(), + quantidadeAtendida: v.optional(v.number()), + observacoes: v.optional(v.string()) + }) + .index('by_requisicaoId', ['requisicaoId']) + .index('by_materialId', ['materialId']), + + historicoAlteracoes: defineTable({ + tipoEntidade: v.string(), + entidadeId: v.string(), + acao: v.string(), + usuarioId: v.id('usuarios'), + dadosAnteriores: v.optional(v.string()), + dadosNovos: v.optional(v.string()), + timestamp: v.number(), + ipAddress: v.optional(v.string()), + observacoes: v.optional(v.string()) + }) + .index('by_tipoEntidade', ['tipoEntidade']) + .index('by_entidadeId', ['entidadeId']) + .index('by_usuarioId', ['usuarioId']) + .index('by_timestamp', ['timestamp']), + + alertasEstoque: defineTable({ + materialId: v.id('materiais'), + tipo: alertaTipo, + quantidadeAtual: v.number(), + quantidadeMinima: v.number(), + status: alertaStatus, + criadoEm: v.number(), + resolvidoEm: v.optional(v.number()), + resolvidoPor: v.optional(v.id('usuarios')) + }) + .index('by_materialId', ['materialId']) + .index('by_status', ['status']) + .index('by_tipo', ['tipo']), + + configuracoesAlmoxarifado: defineTable({ + estoqueMinimoPadrao: v.number(), + diasAntecedenciaAlerta: v.number(), + permitirEstoqueNegativo: v.boolean(), + requerAprovacaoRequisicao: v.boolean(), + rolesAprovacao: v.array(v.string()), + emailAlertasAtivo: v.boolean(), + emailsDestinatarios: v.array(v.string()), + periodicidadeInventario: v.number(), + ultimoInventario: v.optional(v.number()), + ativo: v.boolean(), + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }) +}; + +