diff --git a/apps/web/package.json b/apps/web/package.json index dc6ddb4..28fa6d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "emoji-picker-element": "^1.27.0", "eslint": "catalog:", "exceljs": "^4.4.0", + "html5-qrcode": "^2.3.8", "is-network-error": "^1.3.0", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 45c0c96..046dd22 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -12,7 +12,8 @@ Tag, Users, Briefcase, - UserPlus + UserPlus, + Package } from 'lucide-svelte'; import { resolve } from '$app/paths'; import { page } from '$app/state'; @@ -141,6 +142,58 @@ } ] }, + { + label: 'Almoxarifado', + icon: 'Package', + link: '/almoxarifado', + permission: { recurso: 'almoxarifado', acao: 'listar' }, + submenus: [ + { + label: 'Dashboard', + link: '/almoxarifado', + permission: { recurso: 'almoxarifado', acao: 'listar' }, + excludePaths: [ + '/almoxarifado/materiais', + '/almoxarifado/materiais/cadastro', + '/almoxarifado/movimentacoes', + '/almoxarifado/requisicoes', + '/almoxarifado/alertas', + '/almoxarifado/relatorios' + ] + }, + { + label: 'Cadastrar Material', + link: '/almoxarifado/materiais/cadastro', + permission: { recurso: 'almoxarifado', acao: 'criar_material' } + }, + { + label: 'Listar Materiais', + link: '/almoxarifado/materiais', + permission: { recurso: 'almoxarifado', acao: 'listar' }, + excludePaths: ['/almoxarifado/materiais/cadastro'] + }, + { + label: 'Movimentações', + link: '/almoxarifado/movimentacoes', + permission: { recurso: 'almoxarifado', acao: 'registrar_movimentacao' } + }, + { + label: 'Requisições', + link: '/almoxarifado/requisicoes', + permission: { recurso: 'almoxarifado', acao: 'listar' } + }, + { + label: 'Alertas', + link: '/almoxarifado/alertas', + permission: { recurso: 'almoxarifado', acao: 'listar' } + }, + { + label: 'Relatórios', + link: '/almoxarifado/relatorios', + permission: { recurso: 'almoxarifado', acao: 'listar' } + } + ] + }, { label: 'Objetos', icon: 'Tag', @@ -271,7 +324,8 @@ ChevronDown, GitMerge, Settings, - Tag + Tag, + Package }; function getIconComponent(name: string): IconType { 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/BarcodeScanner.svelte b/apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte new file mode 100644 index 0000000..fd3e21d --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte @@ -0,0 +1,349 @@ + + +
+ {#if enabled} +
+
+
+

+ + Leitor de Código de Barras +

+ +
+ + {#if error} +
+ {error} + +
+ {/if} + + +
+
+ {#if scanning} +
+

+ Posicione o código de barras dentro da área de leitura +

+

+ Ou use um leitor USB/Bluetooth para escanear +

+
+ {:else if !error} +
+ +

Iniciando scanner...

+
+ {/if} +
+ +
+ +
+
+
+ {:else} + + {/if} +
+ + + 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/ImageUpload.svelte b/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte new file mode 100644 index 0000000..be83f5f --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte @@ -0,0 +1,442 @@ + + +
+ + + {#if preview} +
+ Preview da imagem do produto + +
+ {:else} +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + triggerFileInput(); + } + }} + > + +

Clique para fazer upload da imagem

+

+ PNG, JPG ou GIF até {maxSizeMB}MB +

+
+
ou
+ +
+ {/if} + + {#if error} +
+ {error} +
+ {/if} + + {#if preview} +
+ + +
+ {/if} +
+ + +{#if showCamera} +
+
e.stopPropagation()} + > +
+

Capturar Foto

+ +
+ +
+ {#if showCamera} + + {/if} + {#if !capturing} +
+
+ +

Iniciando câmera...

+
+
+ {/if} +
+ +
+ + +
+
+
+{/if} + + 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/lib/components/ferias/WizardSolicitacaoFerias.svelte b/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte index 67df941..170c15a 100644 --- a/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte +++ b/apps/web/src/lib/components/ferias/WizardSolicitacaoFerias.svelte @@ -1,6 +1,8 @@ + +
+ + + + +
+
+
+ +
+
+

Almoxarifado

+

+ Controle de estoque e gestão de materiais +

+
+
+
+ + + {#if statsQuery === undefined} +
+ +
+ {:else 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} + + +
+
+
+

+
+ +
+ Últimos 10 Produtos Cadastrados +

+ {#if ultimosProdutosQuery === undefined} +
+ +
+ {:else if ultimosProdutosQuery.data && ultimosProdutosQuery.data.length > 0} +
+ +
+
+

Mostrando os últimos 10 produtos cadastrados ordenados por data de criação

+
+ {:else} +
+ + Nenhum produto cadastrado ainda +
+ {/if} +
+
+
+ + +
+
+
+

+
+ +
+ Alertas de Estoque +

+ {#if alertasQuery === undefined} +
+ +
+ {:else if alertasQuery.data && alertasQuery.data.length > 0} +
+ + + + + + + + + + + + {#each alertasQuery.data.slice(0, 5) as alerta} + {@const material = materiaisMap.get(alerta.materialId)} + + + + + + + + {/each} + +
MaterialTipoQuantidade AtualQuantidade MínimaAções
+
{material?.nome || 'Carregando...'}
+ {#if material?.codigo} +
{material.codigo}
+ {/if} +
+ {#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)/almoxarifado/alertas/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/alertas/+page.svelte new file mode 100644 index 0000000..c80f8bf --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/alertas/+page.svelte @@ -0,0 +1,319 @@ + + +
+ + + + +
+
+
+ +
+
+

+ Alertas de Estoque +

+

Visualize e gerencie alertas de estoque baixo

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
+
+ +
+

Filtros de Busca

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

Lista de Alertas

+
+ {#if alertasQuery === undefined} +
+ +
+ {:else if alertasQuery.data && alertasQuery.data.length > 0} +
+ + + + + + + + + + + + + + + {#each alertasQuery.data as alerta} + {@const material = materiaisMap.get(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} +
+
+ + {#if alertasQuery.data.length > 0} +
+
+ Mostrando {alertasQuery.data.length} alerta{alertasQuery.data.length !== 1 ? 's' : ''} +
+
+ {/if} + {:else} +
+ +

Nenhum alerta encontrado

+

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

+
+ +
+

Como os alertas funcionam?

+
    +
  • Os alertas são criados automaticamente quando o estoque de um material fica abaixo do mínimo configurado
  • +
  • O sistema permite apenas um alerta ativo por material para evitar duplicações
  • +
  • Quando o estoque volta ao normal, você pode resolver o alerta manualmente
  • +
  • Alertas são criados durante movimentações de estoque (entradas, saídas, ajustes)
  • +
+
+
+
+ {/if} +
+
+ + + alertaParaIgnorar && ignorarAlerta(alertaParaIgnorar)} + onCancel={fecharModalIgnorar} + /> +
+ + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte new file mode 100644 index 0000000..aa9ed14 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/+page.svelte @@ -0,0 +1,713 @@ + + +
+ + + + +
+
+
+
+ +
+
+

+ Materiais +

+

+ Gerencie o cadastro e controle de materiais do almoxarifado +

+
+
+ +
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
+
+
+ +
+

Filtros de Busca

+
+ console.error('Erro no scanner:', error)} + /> +
+
+
+ +
+ + + {#if buscandoPorCodigoBarras} + + {/if} +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+
+ +
+

Lista de Materiais

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

+ Nenhum material encontrado +

+

+ Tente ajustar os filtros de busca ou cadastre um novo material +

+
+
+
{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} +
+
+ + + + + + + + + {#if erroExclusao} + + + + + {/if} +
diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte new file mode 100644 index 0000000..227bb4a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/+page.svelte @@ -0,0 +1,272 @@ + + +{#if loading} +
+ +
+{:else if material} +
+ + + + +
+
+
+ +
+ +
+
+

{material.nome}

+

Detalhes do material

+
+
+ +
+
+ + +
+ + {#if material.imagemBase64} +
+
+

+
+ +
+ Imagem do Produto +

+
+ {material.nome} +
+
+
+ {/if} + + +
+
+

+
+ +
+ Informações Básicas +

+
+
+ +

{material.codigo}

+
+ {#if material.codigoBarras} +
+ +

{material.codigoBarras}

+
+ {/if} +
+ +

{material.nome}

+
+ {#if material.descricao} +
+ +

{material.descricao}

+
+ {/if} +
+ +
+ {material.categoria} +
+
+
+ +

{material.unidadeMedida}

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

+
+ +
+ Estoque +

+
+
+ +
+

{material.estoqueAtual}

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

{material.estoqueMinimo} {material.unidadeMedida}

+
+ {#if material.estoqueMaximo !== undefined} +
+ +

{material.estoqueMaximo} {material.unidadeMedida}

+
+ {/if} + {#if material.estoqueAtual <= material.estoqueMinimo} +
+ + Estoque abaixo do mínimo! Reposição necessária. +
+ {/if} +
+
+
+
+ + +
+
+

+
+ +
+ Informações Adicionais +

+
+ {#if material.localizacao} +
+ +

{material.localizacao}

+
+ {/if} + {#if material.fornecedor} +
+ +

{material.fornecedor}

+
+ {/if} +
+
+
+
+{:else} +
+
+ +

Material não encontrado

+ +
+
+{/if} + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte new file mode 100644 index 0000000..5a60ffe --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte @@ -0,0 +1,417 @@ + + +{#if loadingData} +
+ +
+{:else} +
+ + + + +
+
+ +
+ +
+
+

Editar Material

+

Atualize as informações do material

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
{ e.preventDefault(); handleSubmit(); }}> +
+ +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + {#each categoriasComuns as cat} + +
+ + +
+ + +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 0) { + (e.target as HTMLInputElement).value = ''; + estoqueMinimo = 0; + } else { + estoqueMinimo = value; + } + }} + /> +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 0) { + (e.target as HTMLInputElement).value = ''; + estoqueMaximo = 0; + } else { + estoqueMaximo = value; + } + }} + /> + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+
+
+{/if} + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte new file mode 100644 index 0000000..07aa102 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte @@ -0,0 +1,1618 @@ + + +
+ + + + +
+
+ +
+ +
+
+

+ Cadastrar Material +

+

+ Preencha as informações abaixo para adicionar um novo material ao estoque +

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
{ + e.preventDefault(); + handleSubmit(); + }} + > + +
+
+
+ +
+

Identificação do Material

+
+ + +
+ mostrarMensagem('error', error)} + /> +
+ +
+ +
+ + + +
+ + +
+ + verificarEExecutarBusca(codigoBarras)} + /> + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + {#each categoriasComuns as cat} + +
+ + +
+ + +
+
+
+ + +
+
+
+ +
+

Controle de Estoque

+
+ +
+ +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 0) { + (e.target as HTMLInputElement).value = ''; + estoqueMinimo = 0; + } else { + estoqueMinimo = value; + } + }} + /> + +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 0) { + (e.target as HTMLInputElement).value = ''; + estoqueMaximo = 0; + } else { + estoqueMaximo = value; + } + }} + /> + +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 0) { + (e.target as HTMLInputElement).value = ''; + estoqueAtual = 0; + } else { + estoqueAtual = value; + } + }} + /> + +
+ + +
+ + + +
+
+
+ + +
+
+
+ +
+

Informações Adicionais

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

Imagem do Produto

+
+ +
+ + + +
+
+ + +
+ + +
+
+
+
+ + + {#if modalDadosExternos && dadosExternos} + + {/if} + + + {#if modalDadosLocais && dadosLocais} + + {/if} + + + {#if modalBuscando} + + {/if} + + + {#if modalProdutoNaoEncontrado} + + {/if} +
+ + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/movimentacoes/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/movimentacoes/+page.svelte new file mode 100644 index 0000000..f7716ec --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/movimentacoes/+page.svelte @@ -0,0 +1,860 @@ + + +
+ + + + +
+
+
+ +
+
+

+ Movimentações de Estoque +

+

Registre entradas, saídas e ajustes de estoque do almoxarifado

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

Registrar Entrada de Material

+
+
{ e.preventDefault(); registrarEntrada(); }}> + +
+ mostrarMensagem('error', error)} + /> +
+ +
+ +
+ + + +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 1) { + (e.target as HTMLInputElement).value = ''; + entradaQuantidade = 0; + } else { + entradaQuantidade = value; + } + }} + /> + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'saida'} +
+
+
+
+ +
+

Registrar Saída de Material

+
+
{ e.preventDefault(); registrarSaida(); }}> + +
+ mostrarMensagem('error', error)} + /> +
+ +
+ +
+ + + +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 1) { + (e.target as HTMLInputElement).value = ''; + saidaQuantidade = 0; + } else { + saidaQuantidade = value; + } + }} + /> + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'ajuste'} +
+
+
+
+ +
+

Ajustar Estoque

+
+
+ + Atenção: Ajustes de estoque devem ser justificados e são registrados permanentemente no histórico. +
+
{ e.preventDefault(); ajustarEstoque(); }}> +
+ +
+ + +
+ + +
+ + { + const value = parseInt((e.target as HTMLInputElement).value); + if (isNaN(value) || value < 0) { + (e.target as HTMLInputElement).value = ''; + ajusteQuantidadeNova = 0; + } else { + ajusteQuantidadeNova = value; + } + }} + /> + +
+ + +
+ + + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'historico'} +
+
+
+
+ +
+

Histórico de Movimentações

+
+
+ + + + + + + + + + + + + + + + {#if movimentacoesQuery.data && movimentacoesQuery.data.length > 0} + {#each movimentacoesQuery.data.slice(0, 100) as item} + {@const material = materiaisMap.get(item.materialId)} + {@const funcionario = item.funcionarioId ? funcionariosMap.get(item.funcionarioId) : null} + {@const isMovimentacao = item.tipo === 'movimentacao'} + {@const isAlteracao = item.tipo === 'alteracao'} + + + + + + + + + + + + {/each} + {:else} + + + + {/if} + +
DataMaterialTipoQuantidadeAnteriorNovaFuncionárioUsuárioMotivo
+ {new Date(item.data).toLocaleString('pt-BR')} + + {#if isAlteracao && item.tipoAlteracao === 'exclusao'} +
+ Material excluído (ID: {item.materialId}) +
+ {:else if material} +
{material.nome}
+ {#if material.codigo} +
{material.codigo}
+ {/if} + {:else} +
Material não encontrado
+
ID: {item.materialId}
+ {/if} +
+ {#if isMovimentacao} + {#if item.tipoMovimentacao === 'entrada'} + Entrada + {:else if item.tipoMovimentacao === 'saida'} + Saída + {:else} + Ajuste + {/if} + {:else if isAlteracao} + {#if item.tipoAlteracao === 'criacao'} + Criação + {:else if item.tipoAlteracao === 'edicao'} + Edição + {:else if item.tipoAlteracao === 'exclusao'} + Exclusão + {:else} + {item.tipoAlteracao} + {/if} + {/if} + + {#if isMovimentacao && item.quantidade !== undefined} + {item.quantidade} + {:else} + + {/if} + + {#if isMovimentacao && item.quantidadeAnterior !== undefined} + {item.quantidadeAnterior} + {:else} + + {/if} + + {#if isMovimentacao && item.quantidadeNova !== undefined} + {item.quantidadeNova} + {:else} + + {/if} + + {#if funcionario} +
{funcionario.nome}
+ {#if funcionario.matricula} +
Mat: {funcionario.matricula}
+ {/if} + {:else} + + {/if} +
+ {#if item.usuarioNome} +
{item.usuarioNome}
+ {#if isAlteracao} +
+ {#if item.tipoAlteracao === 'criacao'} + Criou + {:else if item.tipoAlteracao === 'edicao'} + Editou + {:else if item.tipoAlteracao === 'exclusao'} + Excluiu + {/if} +
+ {/if} + {:else} + + {/if} +
+ {item.motivo || '—'} +
+
+ +

Nenhuma movimentação registrada

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

+ Relatórios +

+

Estatísticas e relatórios do almoxarifado

+
+
+
+ + + {#if statsQuery.data} +
+
+
+ +
+

Estatísticas Gerais

+
+
+
+
+
+
+
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} + + +
+
+
+ +
+

Relatórios Disponíveis

+
+
+ +
+
+
+
+
+ +
+

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} +
+
+ Estoque: {alerta.quantidadeAtual} / Mínimo: {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)/almoxarifado/requisicoes/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/requisicoes/+page.svelte new file mode 100644 index 0000000..bcfdfd9 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/requisicoes/+page.svelte @@ -0,0 +1,1300 @@ + + +
+ + + + +
+
+
+
+ +
+
+

+ Requisições de Material +

+

Gerencie requisições de material dos funcionários

+
+
+ +
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + + + + +
+
+
+
+ +
+

Filtros de Busca

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

Lista de Requisições

+
+
+ + + + + + + + + + + + + {#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

+

Tente ajustar os filtros ou criar uma nova requisição

+
+
+
{requisicao.numero}
+
+
{solicitante?.nome || 'Carregando...'}
+
+ {setor?.nome || 'Carregando...'} + +
+ + {getStatusLabel(requisicao.status)} + + {#if requisicao.status === 'rejeitada' && requisicao.motivoReprovacao} +
+ Motivo: {requisicao.motivoReprovacao} +
+ {/if} +
+
+ {new Date(requisicao.criadoEm).toLocaleDateString('pt-BR')} + +
+ + {#if requisicao.status === 'pendente'} + + + {:else if requisicao.status === 'aprovada'} + + {/if} +
+
+
+ + {#if requisicoes.length > 0} +
+
+ Mostrando {requisicoes.length} requisição{requisicoes.length !== 1 ? 'ões' : ''} + {#if requisicoesQuery.data && requisicoesQuery.data.length !== requisicoes.length} + de {requisicoesQuery.data.length} total + {/if} +
+
+ {/if} +
+
+ + + {#if showModalNova} + + {/if} + + + {#if showModalAprovar} + + {/if} + + + {#if showModalReprovar} + + {/if} + + + {#if showModalVisualizar} + + {/if} + + + {#if showModalAtender} + + {/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..092d0d6 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: '/almoxarifado', + Icon: BarChart3 + }, + { + nome: 'Cadastrar Material', + descricao: 'Adicionar novo material ao estoque', + href: '/almoxarifado/materiais/cadastro', + Icon: Plus + }, + { + nome: 'Listar Materiais', + descricao: 'Visualizar e gerenciar materiais', + href: '/almoxarifado/materiais', + Icon: Package + }, + { + nome: 'Movimentações', + descricao: 'Registrar entradas e saídas', + href: '/almoxarifado/movimentacoes', + Icon: ArrowLeftRight + }, + { + nome: 'Requisições', + descricao: 'Gerenciar requisições de material', + href: '/almoxarifado/requisicoes', + Icon: ClipboardList + }, + { + nome: 'Alertas', + descricao: 'Visualizar alertas de estoque baixo', + href: '/almoxarifado/alertas', + Icon: AlertTriangle + }, + { + nome: 'Relatórios', + descricao: 'Relatórios e estatísticas', + href: '/almoxarifado/relatorios', + Icon: BarChart3 + } + ] } ]; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte index 26902f4..4f37d03 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -3149,7 +3149,7 @@ ['Data e Hora', dataHora], ['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'], ['Tolerância', `${registro.toleranciaMinutos} minutos`], - ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'] + ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (Servidor interno)'] ]; if (registro.justificativa) { diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index a0cf4f4..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 = { @@ -272,12 +273,25 @@ { title: 'Configurações de Relógio', description: - 'Configure a sincronização de tempo com servidor NTP ou use o relógio do PC como fallback.', + 'Configure a sincronização de tempo com servidor NTP ou use o servidor interno como fallback.', ctaLabel: 'Configurar Relógio', href: '/(dashboard)/ti/configuracoes-relogio', 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..a37f53c --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte @@ -0,0 +1,677 @@ + + +
+ + + + +
+
+
+ +
+
+

+ 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

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

Configurações de Busca de Código de Barras

+
+ + +
+ +
+

APIs Gratuitas Disponíveis

+

+ As APIs Busca Unificada e Open Food Facts funcionam sem + credenciais e estão sempre disponíveis. Você só precisa configurar as credenciais abaixo + se desejar usar as APIs pagas (GS1 Brasil, Bluesoft Cosmo, Product-Search.net). +

+
+
+ + +
+
+
+ +
+

GS1 Brasil

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

Bluesoft Cosmo

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

Product-Search.net

+
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+ +
+
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte index 039f7df..f5ef5ec 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte @@ -69,7 +69,7 @@ // Date.now() retorna timestamp UTC (milissegundos desde epoch) timestampUTC = Date.now(); timestampOriginal = timestampUTC; - // Atualizar status para indicar que está usando relógio do PC + // Atualizar status para indicar que está usando servidor interno statusSincronizacao = { ultimaSincronizacao: Date.now(), offsetSegundos: null, @@ -305,7 +305,7 @@ 'success', resultado.usandoServidorExterno ? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s | Horário atual: ${horarioAtual}` - : 'Usando relógio do PC (servidor externo não disponível)' + : 'Usando servidor interno (servidor externo não disponível)' ); } else { mostrarMensagem('error', 'Falha na sincronização'); @@ -424,7 +424,7 @@ {statusSincronizacao?.usandoServidorExterno ? `Servidor NTP (${servidorNTP})` - : 'Relógio do PC'} + : 'Servidor interno'} {#if statusSincronizacao?.offsetSegundos !== null && statusSincronizacao?.offsetSegundos !== undefined} @@ -527,7 +527,7 @@
Sincronizar com servidor de tempo externo (NTP) em vez de usar o relógio do PCSincronizar com servidor de tempo externo (NTP) em vez de usar o servidor interno
@@ -577,11 +577,11 @@
Se marcado, o sistema usará o relógio do PC caso não consiga sincronizar com o servidor + >Se marcado, o sistema usará o servidor interno caso não consiga sincronizar com o servidor externo
@@ -639,7 +639,7 @@
Fonte de Tempo
- {statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Relógio do PC'} + {statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Servidor interno'}
diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte index ae3a9ab..daecb76 100644 --- a/apps/web/src/routes/+error.svelte +++ b/apps/web/src/routes/+error.svelte @@ -85,3 +85,4 @@ + diff --git a/bun.lock b/bun.lock index a4a37cf..645aa1c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "sgse-app", @@ -51,6 +50,7 @@ "emoji-picker-element": "^1.27.0", "eslint": "catalog:", "exceljs": "^4.4.0", + "html5-qrcode": "^2.3.8", "is-network-error": "^1.3.0", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", @@ -1189,6 +1189,8 @@ "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index f2a86ce..5d8de54 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -9,11 +9,14 @@ */ import type * as acoes from "../acoes.js"; +import type * as actions_buscarInfoProduto from "../actions/buscarInfoProduto.js"; +import type * as actions_downloadImage from "../actions/downloadImage.js"; import type * as actions_email from "../actions/email.js"; import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js"; import type * as actions_smtp from "../actions/smtp.js"; import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js"; +import type * as almoxarifado from "../almoxarifado.js"; import type * as atas from "../atas.js"; import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as ausencias from "../ausencias.js"; @@ -24,6 +27,8 @@ import type * as chamadas from "../chamadas.js"; import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; import type * as config from "../config.js"; +import type * as configuracaoAlmoxarifado from "../configuracaoAlmoxarifado.js"; +import type * as configuracaoBuscaCodigoBarras from "../configuracaoBuscaCodigoBarras.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoJitsi from "../configuracaoJitsi.js"; import type * as configuracaoPonto from "../configuracaoPonto.js"; @@ -62,6 +67,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"; @@ -103,11 +109,14 @@ import type { declare const fullApi: ApiFromModules<{ acoes: typeof acoes; + "actions/buscarInfoProduto": typeof actions_buscarInfoProduto; + "actions/downloadImage": typeof actions_downloadImage; "actions/email": typeof actions_email; "actions/linkPreview": typeof actions_linkPreview; "actions/pushNotifications": typeof actions_pushNotifications; "actions/smtp": typeof actions_smtp; "actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto; + almoxarifado: typeof almoxarifado; atas: typeof atas; atestadosLicencas: typeof atestadosLicencas; ausencias: typeof ausencias; @@ -118,6 +127,8 @@ declare const fullApi: ApiFromModules<{ chamados: typeof chamados; chat: typeof chat; config: typeof config; + configuracaoAlmoxarifado: typeof configuracaoAlmoxarifado; + configuracaoBuscaCodigoBarras: typeof configuracaoBuscaCodigoBarras; configuracaoEmail: typeof configuracaoEmail; configuracaoJitsi: typeof configuracaoJitsi; configuracaoPonto: typeof configuracaoPonto; @@ -156,6 +167,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/actions/README_BUSCAR_PRODUTO.md b/packages/backend/convex/actions/README_BUSCAR_PRODUTO.md new file mode 100644 index 0000000..796a496 --- /dev/null +++ b/packages/backend/convex/actions/README_BUSCAR_PRODUTO.md @@ -0,0 +1,196 @@ +# Configuração de APIs para Busca de Produtos por Código de Barras + +Este documento descreve como configurar as variáveis de ambiente necessárias para as diferentes APIs de busca de produtos. + +## Fontes de Dados Disponíveis + +O sistema busca produtos em 5 fontes diferentes, em ordem de prioridade: + +1. **GS1 Brasil** (Prioridade 1 - requer credenciais) +2. **Bluesoft Cosmo** (Prioridade 2 - requer credenciais) +3. **Product-Search.net** (Prioridade 3 - pode requerer credenciais) +4. **Busca Unificada** (Prioridade 4 - **GRATUITA, sem credenciais**) +5. **Open Food Facts** (Prioridade 5 - **GRATUITA, sem credenciais**) + +## Configuração das APIs + +### 1. GS1 Brasil (API Verified by GS1) + +A GS1 Brasil oferece a API "Verified by GS1" para consulta de produtos cadastrados. + +#### Passos para obter credenciais: + +1. Acesse o portal: https://apicnp.gs1br.org +2. Faça login ou crie uma conta +3. No menu "Apps", cadastre uma nova aplicação +4. Obtenha o `Client_ID` e `Client_Secret` + +#### Configuração no Convex: + +```bash +# Client ID da aplicação registrada +npx convex env set GS1_BRASIL_CLIENT_ID "seu-client-id-aqui" + +# Client Secret da aplicação +npx convex env set GS1_BRASIL_CLIENT_SECRET "seu-client-secret-aqui" + +# URL do token (opcional, padrão já configurado) +npx convex env set GS1_BRASIL_TOKEN_URL "https://apicnp.gs1br.org/oauth/token" + +# URL da API (opcional, padrão já configurado) +npx convex env set GS1_BRASIL_API_URL "https://apicnp.gs1br.org/api/v1/products" +``` + +#### Documentação: +- Manual: https://www.gs1br.org/educacao-e-eventos/Documents/Manual%20do%20Usu%C3%A1rio%20-%20API%20Verified%20by%20GS1_v.03.pdf +- Portal: https://apicnp.gs1br.org + +--- + +### 2. Bluesoft Cosmos + +A Bluesoft oferece uma API REST para consulta de produtos por código de barras (GTIN/EAN). + +#### Passos para obter credenciais: + +1. Acesse: https://api.cosmos.bluesoft.com.br/api +2. Faça login na plataforma +3. Obtenha seu token de API (X-Cosmos-Token) e User-Agent +4. Ambos estarão disponíveis na página após fazer login + +#### Configuração no Convex: + +```bash +# Token de autenticação da Bluesoft (X-Cosmos-Token) +npx convex env set BLUESOFT_API_KEY "seu-token-aqui" + +# URL da API (opcional, padrão já configurado) +npx convex env set BLUESOFT_API_URL "https://api.cosmos.bluesoft.com.br" +``` + +**Importante**: O sistema usa automaticamente o endpoint correto `/gtins/{codigo}.json` conforme a documentação oficial. + +#### Endpoint: +- **GET** `/gtins/{código}.json` - Recupera detalhes do produto através do GTIN/EAN informado + +#### Estrutura de Resposta: +A API retorna informações incluindo: +- `description`: Nome/descrição do produto +- `brand.name`: Nome da marca +- `gpc.description`: Categoria do produto +- `ncm.code` e `ncm.description`: Código NCM e descrição +- `thumbnail`: URL da imagem do produto +- `price`: Preço formatado +- `gross_weight` e `net_weight`: Peso bruto e líquido + +#### Documentação: +- **Documentação Oficial**: https://api.cosmos.bluesoft.com.br/api +- Central de Ajuda: https://ajuda.bluesoft.com.br/ +- Suporte: suporte.api@bluesoft.com.br + +--- + +### 3. Product-Search.net + +Plataforma que fornece informações sobre produtos com base em códigos de barras. + +#### Passos para obter credenciais (opcional): + +1. Acesse: https://product-search.net +2. Crie uma conta (se necessário) +3. Obtenha sua API key (se disponível no seu plano) + +#### Configuração no Convex: + +```bash +# API Key (opcional - pode funcionar sem autenticação dependendo do plano) +npx convex env set PRODUCT_SEARCH_API_KEY "sua-api-key-aqui" + +# URL da API (opcional, padrão já configurado) +npx convex env set PRODUCT_SEARCH_API_URL "https://api.product-search.net/v1/products" +``` + +#### Documentação: +- Site: https://product-search.net + +--- + +### 4. Busca Unificada + +API gratuita e pública para busca de produtos por código de barras. **Não requer configuração ou credenciais.** + +#### Características: + +- ✅ Totalmente gratuita para uso pessoal e comercial +- ✅ Sem necessidade de cadastro ou credenciais +- ✅ Sempre disponível +- ✅ Foco em produtos brasileiros + +#### Documentação: +- Site: https://api-produtos.seunegocionanuvem.com.br + +--- + +### 5. Open Food Facts + +API gratuita e pública, não requer configuração. Sempre disponível como fallback. + +#### Características: + +- ✅ Totalmente gratuita e colaborativa +- ✅ Sem necessidade de cadastro ou credenciais +- ✅ Foco em produtos alimentícios +- ✅ Base de dados colaborativa internacional + +--- + +## Ordem de Prioridade + +O sistema tenta buscar em todas as fontes em paralelo e retorna o primeiro resultado válido encontrado, seguindo esta ordem de prioridade: + +1. **GS1 Brasil** (requer credenciais) +2. **Bluesoft Cosmo** (requer credenciais) +3. **Product-Search.net** (pode requerer credenciais) +4. **Busca Unificada** (gratuita, sem credenciais) ⭐ +5. **Open Food Facts** (gratuita, sem credenciais) ⭐ + +> ⭐ **Nota**: As APIs gratuitas (Busca Unificada e Open Food Facts) funcionam sem nenhuma configuração adicional e estão sempre disponíveis. + +## Verificação da Configuração + +Para verificar se as variáveis de ambiente estão configuradas: + +```bash +npx convex env list +``` + +## Notas Importantes + +1. **Limites de Uso**: Cada API pode ter limites de requisições por período. Consulte a documentação de cada serviço. + +2. **Custos**: Algumas APIs podem ter custos associados. Verifique os planos disponíveis. + +3. **Segurança**: As credenciais são armazenadas de forma segura nas variáveis de ambiente do Convex e nunca são expostas ao frontend. + +4. **Fallback**: Se uma API falhar ou não estiver configurada, o sistema automaticamente tenta as outras fontes. As APIs gratuitas (Busca Unificada e Open Food Facts) sempre estarão disponíveis mesmo sem configuração. + +5. **Timeout**: Todas as requisições têm timeout de 5 segundos para evitar travamentos. + +## Testando a Configuração + +Após configurar as variáveis de ambiente, teste a busca com um código de barras conhecido: + +1. Acesse a página de cadastro de materiais +2. Digite ou escaneie um código de barras +3. O sistema deve buscar em todas as fontes configuradas +4. O modal mostrará a fonte dos dados encontrados + +## Suporte + +Em caso de problemas: + +1. Verifique se as credenciais estão corretas +2. Verifique se as URLs dos endpoints estão corretas +3. Consulte os logs do Convex para erros específicos +4. Verifique a documentação oficial de cada API + diff --git a/packages/backend/convex/actions/buscarInfoProduto.ts b/packages/backend/convex/actions/buscarInfoProduto.ts new file mode 100644 index 0000000..2cc56ed --- /dev/null +++ b/packages/backend/convex/actions/buscarInfoProduto.ts @@ -0,0 +1,622 @@ +'use node'; + +import { action } from '../_generated/server'; +import { v } from 'convex/values'; +import { api, internal } from '../_generated/api'; +import { decryptSMTPPassword } from '../auth/utils'; + +interface OpenFoodFactsProduct { + product?: { + product_name?: string; + product_name_pt?: string; + generic_name?: string; + generic_name_pt?: string; + categories?: string; + categories_tags?: string[]; + image_url?: string; + image_front_url?: string; + image_front_small_url?: string; + brands?: string; + quantity?: string; + packaging?: string; + }; + status?: number; + status_verbose?: string; +} + +interface GS1BrasilProduct { + gtin?: string; + description?: string; + brand?: string; + brandName?: string; + category?: string; + categoryName?: string; + imageUrl?: string; + image?: string; + ncm?: string; + unidadeMedida?: string; + pesoBruto?: string; +} + +interface BluesoftProduct { + gtin?: number | string; + description?: string; + brand?: { + name?: string; + picture?: string; + }; + gpc?: { + code?: string; + description?: string; + }; + ncm?: { + code?: string; + description?: string; + full_description?: string; + }; + thumbnail?: string; + price?: string; + avg_price?: number; + max_price?: number; + gross_weight?: number; + net_weight?: number; + height?: number; + width?: number; + length?: number; +} + +interface ProductSearchProduct { + gtin?: string; + name?: string; + description?: string; + brand?: string; + category?: string; + imageUrl?: string; + image?: string; +} + +interface BuscaUnificadaProduct { + produto?: { + nome?: string; + descricao?: string; + categoria?: string; + marca?: string; + imagem?: string; + imagemUrl?: string; + ean?: string; + }; + sucesso?: boolean; + mensagem?: string; +} + +interface ProductInfo { + nome?: string; + descricao?: string; + categoria?: string; + imagemUrl?: string; + marca?: string; + quantidade?: string; + embalagem?: string; + fonte?: string; +} + +interface GS1TokenResponse { + access_token?: string; + token_type?: string; + expires_in?: number; +} + +/** + * Busca informações de produto via múltiplas APIs externas + * Fontes: Open Food Facts, Busca Unificada, GS1 Brasil, Bluesoft Cosmo, Product-Search.net + * + * A busca é feita em paralelo em todas as fontes disponíveis e retorna + * o primeiro resultado válido encontrado. + */ +interface ConfigBuscaCodigoBarras { + gs1BrasilClientId?: string; + gs1BrasilClientSecret?: string; + gs1BrasilTokenUrl?: string; + gs1BrasilApiUrl?: string; + gs1BrasilAtivo: boolean; + bluesoftApiKey?: string; + bluesoftApiUrl?: string; + bluesoftAtivo: boolean; + productSearchApiKey?: string; + productSearchApiUrl?: string; + productSearchAtivo: boolean; +} + +export const buscarInfoProdutoPorCodigoBarras = action({ + args: { + codigoBarras: v.string() + }, + handler: async (ctx, args): Promise => { + const { codigoBarras } = args; + + // Validar formato básico de código de barras (EAN-13, UPC, etc.) + if (!codigoBarras || codigoBarras.length < 8 || codigoBarras.length > 14) { + return null; + } + + // Obter configurações do banco de dados + const configDb = await ctx.runQuery( + internal.configuracaoBuscaCodigoBarras.obterConfigBuscaCodigoBarrasInternal + ); + + // Preparar configurações (com descriptografia de credenciais) + let config: ConfigBuscaCodigoBarras = { + gs1BrasilAtivo: false, + bluesoftAtivo: false, + productSearchAtivo: false + }; + + if (configDb) { + // Descriptografar credenciais se existirem + let gs1BrasilClientSecret: string | undefined; + if (configDb.gs1BrasilClientSecret) { + try { + gs1BrasilClientSecret = await decryptSMTPPassword(configDb.gs1BrasilClientSecret); + } catch (error) { + console.error('Erro ao descriptografar GS1 Client Secret:', error); + } + } + + let bluesoftApiKey: string | undefined; + if (configDb.bluesoftApiKey) { + try { + bluesoftApiKey = await decryptSMTPPassword(configDb.bluesoftApiKey); + } catch (error) { + console.error('Erro ao descriptografar Bluesoft API Key:', error); + } + } + + let productSearchApiKey: string | undefined; + if (configDb.productSearchApiKey) { + try { + productSearchApiKey = await decryptSMTPPassword(configDb.productSearchApiKey); + } catch (error) { + console.error('Erro ao descriptografar Product-Search API Key:', error); + } + } + + config = { + gs1BrasilClientId: configDb.gs1BrasilClientId, + gs1BrasilClientSecret, + gs1BrasilTokenUrl: configDb.gs1BrasilTokenUrl, + gs1BrasilApiUrl: configDb.gs1BrasilApiUrl, + gs1BrasilAtivo: configDb.gs1BrasilAtivo, + bluesoftApiKey, + bluesoftApiUrl: configDb.bluesoftApiUrl, + bluesoftAtivo: configDb.bluesoftAtivo, + productSearchApiKey, + productSearchApiUrl: configDb.productSearchApiUrl, + productSearchAtivo: configDb.productSearchAtivo + }; + } + + // Tentar buscar em todas as fontes em paralelo + // Usamos Promise.allSettled para não falhar se uma fonte falhar + const resultados = await Promise.allSettled([ + buscarOpenFoodFacts(codigoBarras), // 0 + buscarBuscaUnificada(codigoBarras), // 1 - Gratuita, sem credenciais + buscarGS1Brasil(codigoBarras, config), // 2 + buscarBluesoftCosmo(codigoBarras, config), // 3 + buscarProductSearch(codigoBarras, config) // 4 + ]); + + // Processar resultados e retornar o primeiro que tiver dados válidos + // Prioridade: GS1 Brasil > Bluesoft > Product-Search > Busca Unificada > Open Food Facts + const ordemPrioridade = [2, 3, 4, 1, 0]; // Índices das fontes por prioridade + + for (const indice of ordemPrioridade) { + const resultado = resultados[indice]; + if (resultado.status === 'fulfilled' && resultado.value) { + return resultado.value; + } + } + + // Se nenhuma fonte retornou dados, retornar null + return null; + } +}); + +/** + * Busca na API de Produtos por Código de Barras (Busca Unificada) + * Gratuita, sem necessidade de credenciais + * Site: https://api-produtos.seunegocionanuvem.com.br + */ +async function buscarBuscaUnificada(codigoBarras: string): Promise { + try { + const apiUrl = 'https://api-produtos.seunegocionanuvem.com.br/api/produtos'; + const response = await fetch(`${apiUrl}?ean=${codigoBarras}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' + }, + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as BuscaUnificadaProduct; + + if (!data.produto || (!data.produto.nome && !data.produto.descricao)) { + return null; + } + + const produto = data.produto; + + const info: ProductInfo = { + nome: produto.nome || undefined, + descricao: produto.descricao || undefined, + categoria: produto.categoria || undefined, + imagemUrl: produto.imagemUrl || produto.imagem || undefined, + marca: produto.marca || undefined, + fonte: 'Busca Unificada' + }; + + if (info.nome || info.descricao) { + return info; + } + + return null; + } catch (error) { + console.error('Erro ao buscar Busca Unificada:', error); + return null; + } +} + +/** + * Busca na Open Food Facts (gratuita, sem autenticação) + */ +async function buscarOpenFoodFacts(codigoBarras: string): Promise { + try { + const response = await fetch( + `https://world.openfoodfacts.org/api/v0/product/${codigoBarras}.json`, + { + method: 'GET', + headers: { + 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' + }, + signal: AbortSignal.timeout(5000) + } + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as OpenFoodFactsProduct; + + if (data.status !== 1 || !data.product) { + return null; + } + + const product = data.product; + + // Extrair categoria + let categoria: string | undefined; + if (product.categories_tags && product.categories_tags.length > 0) { + const primeiraCategoria = product.categories_tags[0]; + categoria = primeiraCategoria + .replace(/^pt:/, '') + .replace(/^en:/, '') + .replace(/-/g, ' ') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } else if (product.categories) { + categoria = product.categories.split(',')[0].trim(); + } + + const info: ProductInfo = { + nome: product.product_name_pt || product.product_name || undefined, + descricao: product.generic_name_pt || product.generic_name || undefined, + categoria, + imagemUrl: + product.image_front_url || product.image_url || product.image_front_small_url || undefined, + marca: product.brands || undefined, + quantidade: product.quantity || undefined, + embalagem: product.packaging || undefined, + fonte: 'Open Food Facts' + }; + + if (info.nome || info.descricao) { + return info; + } + + return null; + } catch (error) { + console.error('Erro ao buscar Open Food Facts:', error); + return null; + } +} + +/** + * Obtém token de autenticação da GS1 Brasil + */ +async function obterTokenGS1Brasil(config: ConfigBuscaCodigoBarras): Promise { + try { + // Priorizar configurações do banco, fallback para variáveis de ambiente + const clientId = config.gs1BrasilClientId || process.env.GS1_BRASIL_CLIENT_ID; + const clientSecret = config.gs1BrasilClientSecret || process.env.GS1_BRASIL_CLIENT_SECRET; + const tokenUrl = + config.gs1BrasilTokenUrl || + process.env.GS1_BRASIL_TOKEN_URL || + 'https://apicnp.gs1br.org/oauth/token'; + + if (!clientId || !clientSecret) { + console.warn('GS1 Brasil: Credenciais não configuradas'); + return null; + } + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret + }), + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + console.error('GS1 Brasil: Erro ao obter token', response.status); + return null; + } + + const data = (await response.json()) as GS1TokenResponse; + return data.access_token || null; + } catch (error) { + console.error('GS1 Brasil: Erro ao obter token:', error); + return null; + } +} + +/** + * Busca na GS1 Brasil (API Verified by GS1) + */ +async function buscarGS1Brasil( + codigoBarras: string, + config: ConfigBuscaCodigoBarras +): Promise { + try { + if (!config.gs1BrasilAtivo) { + return null; + } + + const token = await obterTokenGS1Brasil(config); + if (!token) { + return null; + } + + const apiUrl = + config.gs1BrasilApiUrl || + process.env.GS1_BRASIL_API_URL || + 'https://apicnp.gs1br.org/api/v1/products'; + const response = await fetch(`${apiUrl}/${codigoBarras}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' + }, + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + if (response.status === 404) { + return null; // Produto não encontrado + } + console.error('GS1 Brasil: Erro na consulta', response.status); + return null; + } + + const data = (await response.json()) as GS1BrasilProduct; + + if (!data.gtin && !data.description) { + return null; + } + + const info: ProductInfo = { + nome: data.description || undefined, + descricao: data.description || undefined, + categoria: data.categoryName || data.category || undefined, + imagemUrl: data.imageUrl || data.image || undefined, + marca: data.brandName || data.brand || undefined, + quantidade: data.unidadeMedida || undefined, + fonte: 'GS1 Brasil' + }; + + if (info.nome || info.descricao) { + return info; + } + + return null; + } catch (error) { + console.error('Erro ao buscar GS1 Brasil:', error); + return null; + } +} + +/** + * Busca na Bluesoft Cosmos + * Documentação oficial: https://api.cosmos.bluesoft.com.br/api + * Endpoint: GET /gtins/{código}.json + */ +async function buscarBluesoftCosmo( + codigoBarras: string, + config: ConfigBuscaCodigoBarras +): Promise { + try { + if (!config.bluesoftAtivo) { + return null; + } + + // Priorizar configurações do banco, fallback para variáveis de ambiente + const apiKey = config.bluesoftApiKey || process.env.BLUESOFT_API_KEY; + + // Endpoint base da API - pode ser configurado, mas o padrão é o oficial + const apiBaseUrl = + config.bluesoftApiUrl || + process.env.BLUESOFT_API_URL || + 'https://api.cosmos.bluesoft.com.br'; + + if (!apiKey) { + console.warn('Bluesoft Cosmos: API Key não configurada'); + return null; + } + + // Endpoint correto conforme documentação: /gtins/{codigo}.json + const url = `${apiBaseUrl.replace(/\/$/, '')}/gtins/${codigoBarras}.json`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'X-Cosmos-Token': apiKey, + 'Content-Type': 'application/json', + 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' // User-Agent é obrigatório + }, + signal: AbortSignal.timeout(10000) // 10 segundos de timeout + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Não foi possível ler resposta'); + + if (response.status === 404) { + // Produto não encontrado é normal, não é erro + return null; + } + + if (response.status === 401 || response.status === 403) { + console.error('Bluesoft Cosmos: Erro de autenticação. Verifique a API Key e User-Agent.'); + console.error('Bluesoft Cosmos: Detalhes do erro', { + status: response.status, + statusText: response.statusText, + body: errorText.substring(0, 500) + }); + return null; + } + + if (response.status === 429) { + console.error('Bluesoft Cosmos: Limite de requisições excedido (HTTP 429)'); + return null; + } + + console.error('Bluesoft Cosmos: Erro na consulta', { + status: response.status, + statusText: response.statusText, + url, + body: errorText.substring(0, 500) + }); + return null; + } + + const data = (await response.json()) as BluesoftProduct; + + // Validar se temos dados mínimos + if (!data.gtin && !data.description) { + return null; + } + + // Mapear campos conforme estrutura da API + const info: ProductInfo = { + nome: data.description || undefined, + descricao: data.description || undefined, + categoria: data.gpc?.description || data.ncm?.description || undefined, + imagemUrl: data.thumbnail || undefined, + marca: data.brand?.name || undefined, + quantidade: data.net_weight ? `${data.net_weight}g` : data.gross_weight ? `${data.gross_weight}g` : undefined, + fonte: 'Bluesoft Cosmos' + }; + + if (info.nome || info.descricao) { + return info; + } + + return null; + } catch (error) { + console.error('Bluesoft Cosmos: Erro ao buscar:', error); + if (error instanceof Error) { + console.error('Bluesoft Cosmos: Detalhes do erro:', error.message); + } + return null; + } +} + +/** + * Busca no Product-Search.net + * Pode funcionar com ou sem API key dependendo do plano + */ +async function buscarProductSearch( + codigoBarras: string, + config: ConfigBuscaCodigoBarras +): Promise { + try { + if (!config.productSearchAtivo) { + return null; + } + + // Priorizar configurações do banco, fallback para variáveis de ambiente + const apiKey = config.productSearchApiKey || process.env.PRODUCT_SEARCH_API_KEY; + const apiUrl = + config.productSearchApiUrl || + process.env.PRODUCT_SEARCH_API_URL || + 'https://api.product-search.net/v1/products'; + + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' + }; + + // Adicionar API key se disponível + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetch(`${apiUrl}/${codigoBarras}`, { + method: 'GET', + headers, + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + if (response.status === 404) { + return null; // Produto não encontrado + } + console.error('Product-Search.net: Erro na consulta', response.status); + return null; + } + + const data = (await response.json()) as ProductSearchProduct; + + if (!data.gtin && !data.name && !data.description) { + return null; + } + + const info: ProductInfo = { + nome: data.name || data.description || undefined, + descricao: data.description || undefined, + categoria: data.category || undefined, + imagemUrl: data.imageUrl || data.image || undefined, + marca: data.brand || undefined, + fonte: 'Product-Search.net' + }; + + if (info.nome || info.descricao) { + return info; + } + + return null; + } catch (error) { + console.error('Erro ao buscar Product-Search.net:', error); + return null; + } +} diff --git a/packages/backend/convex/actions/downloadImage.ts b/packages/backend/convex/actions/downloadImage.ts new file mode 100644 index 0000000..f30249c --- /dev/null +++ b/packages/backend/convex/actions/downloadImage.ts @@ -0,0 +1,137 @@ +'use node'; + +import { action } from '../_generated/server'; +import { v } from 'convex/values'; + +/** + * Baixa uma imagem de uma URL externa e converte para base64. + * + * Esta action roda no servidor (Node.js), então não tem restrições de CORS + * do navegador. Pode baixar imagens de qualquer domínio. + * + * @param url - URL da imagem a ser baixada + * @returns String base64 da imagem (data URL) ou null se falhar + */ +export const downloadImageAsBase64 = action({ + args: { + url: v.string() + }, + returns: v.union(v.string(), v.null()), + handler: async (ctx, args): Promise => { + const { url } = args; + + try { + // Validar URL + let urlObj: URL; + try { + urlObj = new URL(url); + } catch { + console.error('URL inválida:', url); + return null; + } + + // Verificar se é uma URL HTTP/HTTPS + if (!['http:', 'https:'].includes(urlObj.protocol)) { + console.error('Protocolo não suportado:', urlObj.protocol); + return null; + } + + // Baixar a imagem (server-side não tem CORS) + // Tentar múltiplas estratégias para evitar bloqueios (403) de CDNs + type HeadersStrategy = Record; + + const estrategias: Array<{ name: string; headers: HeadersStrategy }> = [ + // Estratégia 1: Headers completos de navegador moderno + { + name: 'headers-completos', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + Referer: urlObj.origin + '/', + 'Sec-Fetch-Dest': 'image', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'cross-site', + 'Cache-Control': 'no-cache' + } as HeadersStrategy + }, + // Estratégia 2: Headers mínimos mas realistas + { + name: 'headers-minimos', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + Accept: 'image/*', + Referer: urlObj.origin + '/' + } as HeadersStrategy + }, + // Estratégia 3: Apenas User-Agent básico + { + name: 'user-agent-basico', + headers: { + 'User-Agent': 'Mozilla/5.0' + } as HeadersStrategy + }, + // Estratégia 4: Sem headers (máximo compatibilidade) + { + name: 'sem-headers', + headers: {} as HeadersStrategy + } + ]; + + let ultimoErro: { status?: number; statusText?: string; message?: string } | null = null; + + for (const estrategia of estrategias) { + try { + const response = await fetch(url, { + headers: estrategia.headers, + signal: AbortSignal.timeout(10000) // Timeout de 10 segundos + }); + + if (response.ok) { + // Verificar Content-Type + const contentType = response.headers.get('content-type'); + if (contentType && contentType.startsWith('image/')) { + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const base64 = buffer.toString('base64'); + const dataUrl = `data:${contentType};base64,${base64}`; + console.log( + `✅ Imagem baixada usando estratégia "${estrategia.name}": ${url} (${buffer.length} bytes)` + ); + return dataUrl; + } else { + console.warn(`Estratégia "${estrategia.name}": Content-Type inválido:`, contentType); + } + } else { + ultimoErro = { + status: response.status, + statusText: response.statusText + }; + console.warn( + `Estratégia "${estrategia.name}" falhou: ${response.status} ${response.statusText}` + ); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + ultimoErro = { message: errorMessage }; + console.warn(`Estratégia "${estrategia.name}" lançou exceção:`, errorMessage); + } + } + + // Se todas as estratégias falharam, logar erro detalhado + console.error( + '❌ Todas as estratégias falharam ao baixar imagem:', + url, + 'Último erro:', + ultimoErro + ); + return null; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Erro ao baixar imagem de URL:', url, errorMessage); + return null; + } + } +}); diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts new file mode 100644 index 0000000..d593374 --- /dev/null +++ b/packages/backend/convex/almoxarifado.ts @@ -0,0 +1,1724 @@ +import { v } from 'convex/values'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { internalMutation, internalAction, internalQuery, mutation, query } from './_generated/server'; +import { internal } from './_generated/api'; +import { api } from './_generated/api'; +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 materiais; + if (args.ativo !== undefined) { + materiais = await ctx.db + .query('materiais') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); + } else if (args.categoria) { + materiais = await ctx.db + .query('materiais') + .withIndex('by_categoria', (q) => q.eq('categoria', args.categoria!)) + .collect(); + } else { + materiais = await ctx.db.query('materiais').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) || + (m.codigoBarras && m.codigoBarras.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 buscarMaterialPorCodigoBarras = query({ + args: { codigoBarras: v.string() }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return null; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return null; + } + + const material = await ctx.db + .query('materiais') + .withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras)) + .first(); + + return material ?? null; + } +}); + +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 movimentacoes; + if (args.materialId) { + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId!)) + .collect(); + } else if (args.tipo) { + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) + .collect(); + } else if (args.funcionarioId) { + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId!)) + .collect(); + } else { + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_data') + .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 listarMovimentacoesComHistorico = query({ + args: {}, + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + // Buscar movimentações de estoque + const movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_data') + .collect(); + + // Buscar histórico de alterações de materiais (criação, edição, exclusão) + const historicoMateriais = await ctx.db + .query('historicoAlteracoes') + .withIndex('by_tipoEntidade', (q) => q.eq('tipoEntidade', 'material')) + .collect(); + + // Buscar todos os usuários únicos para enriquecer os dados + const usuarioIds = new Set>(); + for (const mov of movimentacoes) { + usuarioIds.add(mov.usuarioId); + } + for (const hist of historicoMateriais) { + usuarioIds.add(hist.usuarioId); + } + + // Buscar informações dos usuários + const usuariosMap = new Map, Doc<'usuarios'>>(); + for (const userId of usuarioIds) { + const usuario = await ctx.db.get(userId); + if (usuario) { + usuariosMap.set(userId, usuario); + } + } + + // Transformar movimentações em formato unificado + const movimentacoesFormatadas = movimentacoes.map((mov) => { + const usuario = usuariosMap.get(mov.usuarioId); + return { + id: mov._id, + tipo: 'movimentacao' as const, + tipoMovimentacao: mov.tipo, + materialId: mov.materialId, + data: mov.data, + quantidade: mov.quantidade, + quantidadeAnterior: mov.quantidadeAnterior, + quantidadeNova: mov.quantidadeNova, + motivo: mov.motivo, + funcionarioId: mov.funcionarioId, + usuarioId: mov.usuarioId, + usuarioNome: usuario?.nome || 'Usuário desconhecido', + observacoes: mov.observacoes + }; + }); + + // Transformar histórico de alterações em formato unificado + const historicoFormatado = historicoMateriais.map((hist) => { + const usuario = usuariosMap.get(hist.usuarioId); + return { + id: hist._id, + tipo: 'alteracao' as const, + tipoAlteracao: hist.acao, // 'criacao', 'edicao', 'exclusao' + materialId: hist.entidadeId as Id<'materiais'>, // Converter string para Id + data: hist.timestamp, + quantidade: undefined, + quantidadeAnterior: undefined, + quantidadeNova: undefined, + motivo: hist.observacoes || hist.acao, + funcionarioId: undefined, + usuarioId: hist.usuarioId, + usuarioNome: usuario?.nome || 'Usuário desconhecido', + observacoes: hist.observacoes, + dadosAnteriores: hist.dadosAnteriores, + dadosNovos: hist.dadosNovos + }; + }); + + // Combinar e ordenar por data (mais recente primeiro) + const todos = [...movimentacoesFormatadas, ...historicoFormatado]; + todos.sort((a, b) => b.data - a.data); + + return todos; + } +}); + +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 requisicoes; + if (args.status) { + requisicoes = await ctx.db + .query('requisicoesMaterial') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect(); + } else if (args.solicitanteId) { + requisicoes = await ctx.db + .query('requisicoesMaterial') + .withIndex('by_solicitanteId', (q) => q.eq('solicitanteId', args.solicitanteId!)) + .collect(); + } else if (args.setorId) { + requisicoes = await ctx.db + .query('requisicoesMaterial') + .withIndex('by_setorId', (q) => q.eq('setorId', args.setorId!)) + .collect(); + } else { + requisicoes = await ctx.db.query('requisicoesMaterial').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 alertas; + if (args.status) { + alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect(); + } else if (args.tipo) { + alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) + .collect(); + } else { + alertas = await ctx.db.query('alertasEstoque').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); + } +}); + +export const obterUltimosProdutosCadastrados = query({ + args: { + limit: v.optional(v.number()) + }, + 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 []; + } + + const limit = args.limit ?? 10; + const materiais = await ctx.db.query('materiais').collect(); + + // Ordenar por data de criação (mais recente primeiro) e pegar os últimos N + const materiaisOrdenados = materiais + .sort((a, b) => b.criadoEm - a.criadoEm) + .slice(0, limit); + + return materiaisOrdenados.map((m) => ({ + _id: m._id, + nome: m.nome, + codigo: m.codigo, + estoqueAtual: m.estoqueAtual, + unidadeMedida: m.unidadeMedida, + criadoEm: m.criadoEm + })); + } +}); + +// ========== 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 + }); +} + +/** + * Função helper para enviar emails de alertas de almoxarifado + */ +async function enviarEmailAlerta( + ctx: MutationCtx, + alerta: Doc<'alertasEstoque'>, + material: Doc<'materiais'>, + tipoEmail: 'criado' | 'resolvido' | 'ignorado', + usuarioResolucao?: Doc<'usuarios'> +) { + try { + // Buscar configuração de almoxarifado + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + // Verificar se emails de alerta estão ativados + if (!config || !config.emailAlertasAtivo || !config.emailsDestinatarios || config.emailsDestinatarios.length === 0) { + return; // Emails desativados ou sem destinatários + } + + // Determinar template e variáveis baseado no tipo + let templateCodigo: string; + // URL do sistema (buscar de configuração ou usar padrão) + const urlSistema = process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || 'http://localhost:5173'; + + const variaveis: Record = { + materialNome: material.nome, + materialCodigo: material.codigo, + quantidadeAtual: material.estoqueAtual.toString(), + quantidadeMinima: material.estoqueMinimo.toString(), + unidadeMedida: material.unidadeMedida, + urlSistema + }; + + if (tipoEmail === 'criado') { + templateCodigo = 'almoxarifado_alerta_criado'; + const tipoAlertaLabel = + alerta.tipo === 'estoque_zerado' + ? 'Estoque Zerado' + : alerta.tipo === 'estoque_minimo' + ? 'Estoque Mínimo' + : 'Reposição Necessária'; + const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual; + variaveis.tipoAlerta = tipoAlertaLabel; + variaveis.diferenca = diferenca.toString(); + } else if (tipoEmail === 'resolvido') { + templateCodigo = 'almoxarifado_alerta_resolvido'; + const usuarioNome = usuarioResolucao?.nome || 'Sistema'; + const dataResolucao = alerta.resolvidoEm + ? new Date(alerta.resolvidoEm).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + : new Date().toLocaleDateString('pt-BR'); + variaveis.resolvidoPor = usuarioNome; + variaveis.dataResolucao = dataResolucao; + } else { + // ignorado + templateCodigo = 'almoxarifado_alerta_ignorado'; + const tipoAlertaLabel = + alerta.tipo === 'estoque_zerado' + ? 'Estoque Zerado' + : alerta.tipo === 'estoque_minimo' + ? 'Estoque Mínimo' + : 'Reposição Necessária'; + const dataIgnorado = new Date().toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + variaveis.tipoAlerta = tipoAlertaLabel; + variaveis.dataIgnorado = dataIgnorado; + } + + // Buscar usuário atual para enviar o email + const usuarioAtual = await getCurrentUserFunction(ctx); + if (!usuarioAtual) return; + + // Enviar email para cada destinatário configurado + for (const emailDestinatario of config.emailsDestinatarios) { + try { + // Agendar action para enviar email com template (assíncrono, não bloqueia) + ctx.scheduler + .runAfter(0, api.email.enviarEmailComTemplate, { + destinatario: emailDestinatario, + templateCodigo, + variaveis, + enviadoPor: usuarioAtual._id + }) + .catch((error) => { + console.error(`Erro ao agendar email de alerta para ${emailDestinatario}:`, error); + }); + } catch (error) { + console.error(`Erro ao agendar email de alerta para ${emailDestinatario}:`, error); + // Continua para o próximo destinatário mesmo se falhar + } + } + } catch (error) { + console.error('Erro ao enviar emails de alerta:', error); + // Não falha a operação principal se houver erro no email + } +} + +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 + const alertaId = await ctx.db.insert('alertasEstoque', { + materialId, + tipo, + quantidadeAtual: material.estoqueAtual, + quantidadeMinima: material.estoqueMinimo, + status: 'ativo', + criadoEm: Date.now() + }); + + // Buscar alerta criado para enviar email + const alertaCriado = await ctx.db.get(alertaId); + if (alertaCriado) { + // Enviar email de notificação (assíncrono, não bloqueia) + await enviarEmailAlerta(ctx, alertaCriado, material, 'criado'); + } +} + +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()), + codigoBarras: v.optional(v.string()), + imagemUrl: v.optional(v.string()), + imagemBase64: 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'); + } + + // Verificar se código de barras já existe (se fornecido) + if (args.codigoBarras) { + const codigoBarrasExistente = await ctx.db + .query('materiais') + .withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras)) + .first(); + + if (codigoBarrasExistente) { + throw new Error('Código de barras já está cadastrado para outro material'); + } + } + + 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()), + codigoBarras: v.optional(v.string()), + imagemUrl: v.optional(v.string()), + imagemBase64: 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'); + } + } + + // Verificar se código de barras já existe (se foi alterado) + if (args.codigoBarras && args.codigoBarras !== material.codigoBarras) { + const codigoBarrasExistente = await ctx.db + .query('materiais') + .withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras)) + .first(); + + if (codigoBarrasExistente) { + throw new Error('Código de barras já está cadastrado para outro material'); + } + } + + 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.codigoBarras !== undefined) dadosNovos.codigoBarras = args.codigoBarras; + if (args.imagemUrl !== undefined) dadosNovos.imagemUrl = args.imagemUrl; + if (args.imagemBase64 !== undefined) dadosNovos.imagemBase64 = args.imagemBase64; + 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(), + funcionarioId: v.optional(v.id('funcionarios')), + 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'); + + // Se funcionarioId não foi fornecido, usar o do usuário logado (se existir) + const funcionarioId = args.funcionarioId || usuario.funcionarioId; + + 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, + funcionarioId, + 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(), + funcionarioId: v.optional(v.id('funcionarios')), + 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'); + + // Se funcionarioId não foi fornecido, usar o do usuário logado (se existir) + const funcionarioId = args.funcionarioId || usuario.funcionarioId; + + 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, + funcionarioId, + 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 verificarEstoqueRequisicao = query({ + args: { id: v.id('requisicoesMaterial') }, + 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'); + + // Buscar itens da requisição + const itens = await ctx.db + .query('requisicaoItens') + .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) + .collect(); + + const problemasEstoque: Array<{ + materialId: Id<'materiais'>; + materialNome: string; + quantidadeSolicitada: number; + estoqueAtual: number; + falta: number; + }> = []; + + for (const item of itens) { + const material = await ctx.db.get(item.materialId); + if (!material) continue; + + if (material.estoqueAtual < item.quantidadeSolicitada) { + problemasEstoque.push({ + materialId: material._id, + materialNome: material.nome, + quantidadeSolicitada: item.quantidadeSolicitada, + estoqueAtual: material.estoqueAtual, + falta: item.quantidadeSolicitada - material.estoqueAtual + }); + } + } + + return { + temEstoqueSuficiente: problemasEstoque.length === 0, + problemasEstoque + }; + } +}); + +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'); + } + + // Nota: A verificação de estoque é feita no frontend antes de permitir a aprovação + // O botão de aprovar só aparece quando há estoque suficiente + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Buscar funcionário do usuário - primeiro tenta por funcionarioId, depois por email + let funcionario; + if (usuario.funcionarioId) { + funcionario = await ctx.db.get(usuario.funcionarioId); + } + + if (!funcionario) { + 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 reprovarRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial'), + motivoReprovacao: 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 reprovadas'); + } + + if (!args.motivoReprovacao.trim()) { + throw new Error('É necessário informar o motivo da reprovação'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Buscar funcionário do usuário - primeiro tenta por funcionarioId, depois por email + let funcionario; + if (usuario.funcionarioId) { + funcionario = await ctx.db.get(usuario.funcionarioId); + } + + if (!funcionario) { + 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: 'rejeitada', + reprovadoPor: funcionario._id, + dataReprovacao: Date.now(), + motivoReprovacao: args.motivoReprovacao.trim(), + atualizadoEm: Date.now() + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'rejeitada', + reprovadoPor: funcionario._id, + motivoReprovacao: args.motivoReprovacao.trim() + }); + } +}); + +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'); + + // Buscar material antes de atualizar o alerta + const material = await ctx.db.get(alerta.materialId); + if (!material) throw new Error('Material não encontrado'); + + await ctx.db.patch(args.id, { + status: 'resolvido', + resolvidoEm: Date.now(), + resolvidoPor: usuario._id + }); + + // Buscar alerta atualizado para enviar email + const alertaResolvido = await ctx.db.get(args.id); + if (alertaResolvido) { + // Enviar email de notificação (assíncrono, não bloqueia) + await enviarEmailAlerta(ctx, alertaResolvido, material, 'resolvido', usuario); + } + + // 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'); + } + + // Buscar material antes de atualizar o alerta + const material = await ctx.db.get(alerta.materialId); + if (!material) throw new Error('Material não encontrado'); + + await ctx.db.patch(args.id, { + status: 'ignorado' + }); + + // Buscar alerta atualizado para enviar email + const alertaIgnorado = await ctx.db.get(args.id); + if (alertaIgnorado) { + // Enviar email de notificação (assíncrono, não bloqueia) + await enviarEmailAlerta(ctx, alertaIgnorado, material, 'ignorado'); + } + + // Registrar histórico + await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { + status: 'ignorado' + }); + } +}); + +export const deletarMaterial = mutation({ + args: { + id: v.id('materiais') + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'deletar_material' + }); + + const material = await ctx.db.get(args.id); + if (!material) throw new Error('Material não encontrado'); + + // Verificar se há movimentações relacionadas + const movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .first(); + + if (movimentacoes) { + throw new Error('Não é possível excluir material com movimentações de estoque registradas. O material possui histórico de movimentações e deve ser desativado ao invés de excluído.'); + } + + // Verificar se há requisições relacionadas + const requisicoes = await ctx.db + .query('requisicaoItens') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .first(); + + if (requisicoes) { + throw new Error('Não é possível excluir material com requisições registradas. O material possui requisições associadas e deve ser desativado ao invés de excluído.'); + } + + // Verificar se há alertas relacionados + const alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .first(); + + if (alertas) { + // Deletar alertas relacionados + const todosAlertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.id)) + .collect(); + + for (const alerta of todosAlertas) { + await ctx.db.delete(alerta._id); + } + } + + // Registrar histórico antes de deletar + await registrarHistorico(ctx, 'material', args.id.toString(), 'exclusao', material, undefined); + + // Deletar o material + await ctx.db.delete(args.id); + } +}); + +// ========== 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) => { + if (args.ativo !== undefined) { + return await ctx.db + .query('materiais') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); + } + return await ctx.db.query('materiais').collect(); + } +}); + +export const listarAlertasPorMaterial = internalQuery({ + args: { + materialId: v.id('materiais'), + status: v.optional(alertaStatus) + }, + handler: async (ctx, args) => { + const query = ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); + if (args.status) { + return await query.filter((q) => q.eq(q.field('status'), args.status!)).collect(); + } + return await query.collect(); + } +}); + +export const listarAlertasInterno = internalQuery({ + args: { + status: v.optional(alertaStatus) + }, + handler: async (ctx, args) => { + if (args.status) { + return await ctx.db + .query('alertasEstoque') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect(); + } + return await ctx.db.query('alertasEstoque').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/ausencias.ts b/packages/backend/convex/ausencias.ts index 2155289..bb3f6a8 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -922,3 +922,48 @@ export const marcarComoLida = mutation({ return null; } }); + +// Mutation: Excluir solicitação de ausência +export const excluirSolicitacao = mutation({ + args: { + solicitacaoId: v.id('solicitacoesAusencias'), + usuarioId: v.id('usuarios') + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'ausencias', + acao: 'reprovar' + }); + + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) { + throw new Error('Solicitação não encontrada'); + } + + // Apenas solicitações ainda não processadas podem ser excluídas + if (solicitacao.status !== 'aguardando_aprovacao') { + throw new Error('Apenas solicitações pendentes podem ser excluídas'); + } + + // Verificar se o usuário é o criador original da solicitação + const usuario = await ctx.db.get(args.usuarioId); + if (!usuario) { + throw new Error('Usuário não encontrado'); + } + + const usuarioEhFuncionario = usuario.funcionarioId === solicitacao.funcionarioId; + const gestorIdDoFuncionario = await encontrarGestorDoFuncionario( + ctx, + solicitacao.funcionarioId + ); + const usuarioEhGestor = gestorIdDoFuncionario === args.usuarioId; + + if (!usuarioEhFuncionario && !usuarioEhGestor) { + throw new Error('Você não tem permissão para excluir esta solicitação'); + } + + await ctx.db.delete(args.solicitacaoId); + return null; + } +}); diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts index d5b773e..0f1c381 100644 --- a/packages/backend/convex/autenticacao.ts +++ b/packages/backend/convex/autenticacao.ts @@ -1,6 +1,18 @@ import { v } from 'convex/values'; -import { mutation } from './_generated/server'; +import { mutation, type MutationCtx } from './_generated/server'; import { authComponent, updatePassword } from './auth'; +import type { GenericCtx } from '@convex-dev/better-auth'; +import type { DataModel } from './_generated/dataModel'; + +/** + * Helper para converter MutationCtx para GenericCtx do better-auth + * Os tipos são estruturalmente compatíveis, apenas há diferença nas definições de tipo + */ +function toGenericCtx(ctx: MutationCtx): GenericCtx { + // Os tipos são estruturalmente idênticos, apenas há diferença nas definições de tipo + // entre a versão do Convex usada pelo projeto e a usada pelo @convex-dev/better-auth + return ctx as unknown as GenericCtx; +} /** * Alterar senha do usuário autenticado @@ -19,7 +31,7 @@ export const alterarSenha = mutation({ handler: async (ctx, args) => { try { // Verificar se o usuário está autenticado - const authUser = await authComponent.safeGetAuthUser(ctx); + const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx)); if (!authUser) { return { sucesso: false as const, diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts index 76bd874..68811aa 100644 --- a/packages/backend/convex/auth.ts +++ b/packages/backend/convex/auth.ts @@ -3,7 +3,7 @@ import { convex } from '@convex-dev/better-auth/plugins'; import { betterAuth } from 'better-auth'; import { components } from './_generated/api'; import type { DataModel } from './_generated/dataModel'; -import { type MutationCtx, type QueryCtx, query } from './_generated/server'; +import { type MutationCtx, type QueryCtx, type ActionCtx, query } from './_generated/server'; // Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173'; @@ -12,6 +12,22 @@ const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://l // as well as helper methods for general use. export const authComponent = createClient(components.betterAuth); +/** + * Helper type para converter contextos do Convex para GenericCtx do better-auth + * Isso resolve incompatibilidade de tipos entre versões do Convex sem usar 'any' + */ +type ConvexCtx = QueryCtx | MutationCtx | ActionCtx; + +/** + * Função helper para converter contexto do Convex para GenericCtx do better-auth + * Os tipos são estruturalmente compatíveis, apenas há diferença nas definições de tipo + */ +function toGenericCtx(ctx: ConvexCtx): GenericCtx { + // Os tipos são estruturalmente idênticos, apenas há diferença nas definições de tipo + // entre a versão do Convex usada pelo projeto e a usada pelo @convex-dev/better-auth + return ctx as unknown as GenericCtx; +} + export const createAuth = ( ctx: GenericCtx, { optionsOnly } = { optionsOnly: false } @@ -22,7 +38,7 @@ export const createAuth = ( logger: { disabled: optionsOnly }, - trustedOrigins: ['https://vite.kilder.dev'], + trustedOrigins: ['https://vite.kilder.dev', 'http://localhost:5173', 'http://127.0.0.1:5173'], baseURL: siteUrl, database: authComponent.adapter(ctx), // Configure simple, non-verified email/password to get started @@ -43,7 +59,7 @@ export const getCurrentUser = query({ args: {}, handler: async (ctx) => { try { - const authUser = await authComponent.safeGetAuthUser(ctx); + const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx)); if (!authUser) { return; } @@ -81,7 +97,7 @@ export const getCurrentUser = query({ }); export const getCurrentUserFunction = async (ctx: QueryCtx | MutationCtx) => { - const authUser = await authComponent.safeGetAuthUser(ctx); + const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx)); if (!authUser) { return; } @@ -100,7 +116,7 @@ export const createAuthUser = async ( ctx: MutationCtx, args: { nome: string; email: string; password: string } ) => { - const { auth, headers } = await authComponent.getAuth(createAuth, ctx); + const { auth, headers } = await authComponent.getAuth(createAuth, toGenericCtx(ctx)); const result = await auth.api.signUpEmail({ headers, @@ -118,7 +134,7 @@ export const updatePassword = async ( ctx: MutationCtx, args: { newPassword: string; currentPassword: string } ) => { - const { auth, headers } = await authComponent.getAuth(createAuth, ctx); + const { auth, headers } = await authComponent.getAuth(createAuth, toGenericCtx(ctx)); await auth.api.changePassword({ headers, diff --git a/packages/backend/convex/configuracaoAlmoxarifado.ts b/packages/backend/convex/configuracaoAlmoxarifado.ts new file mode 100644 index 0000000..17a4904 --- /dev/null +++ b/packages/backend/convex/configuracaoAlmoxarifado.ts @@ -0,0 +1,173 @@ +import { v } from 'convex/values'; +import { internalQuery, mutation, query } from './_generated/server'; +import { internal } from './_generated/api'; +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') + .withIndex('by_ativo', (q) => q.eq('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 (sem incluir _id e campos de sistema) + const dadosNovos = { + estoqueMinimoPadrao: args.estoqueMinimoPadrao ?? config.estoqueMinimoPadrao, + diasAntecedenciaAlerta: args.diasAntecedenciaAlerta ?? config.diasAntecedenciaAlerta, + permitirEstoqueNegativo: args.permitirEstoqueNegativo ?? config.permitirEstoqueNegativo, + requerAprovacaoRequisicao: args.requerAprovacaoRequisicao ?? config.requerAprovacaoRequisicao, + rolesAprovacao: args.rolesAprovacao ?? config.rolesAprovacao, + emailAlertasAtivo: args.emailAlertasAtivo ?? config.emailAlertasAtivo, + emailsDestinatarios: args.emailsDestinatarios ?? config.emailsDestinatarios, + periodicidadeInventario: args.periodicidadeInventario ?? config.periodicidadeInventario, + ultimoInventario: args.ultimoInventario ?? config.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: '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') + .withIndex('by_ativo', (q) => q.eq('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/configuracaoBuscaCodigoBarras.ts b/packages/backend/convex/configuracaoBuscaCodigoBarras.ts new file mode 100644 index 0000000..7075e60 --- /dev/null +++ b/packages/backend/convex/configuracaoBuscaCodigoBarras.ts @@ -0,0 +1,166 @@ +import { v } from 'convex/values'; +import { mutation, query, internalQuery } from './_generated/server'; +import { encryptSMTPPassword } from './auth/utils'; +import { registrarAtividade } from './logsAtividades'; + +/** + * Obter configuração de busca de código de barras ativa (credenciais mascaradas) + */ +export const obterConfigBuscaCodigoBarras = query({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query('configuracaoBuscaCodigoBarras') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + return null; + } + + // Retornar config com credenciais mascaradas + return { + _id: config._id, + // GS1 Brasil + gs1BrasilClientId: config.gs1BrasilClientId || '', + gs1BrasilClientSecret: '********', // Mascarar + gs1BrasilTokenUrl: config.gs1BrasilTokenUrl || '', + gs1BrasilApiUrl: config.gs1BrasilApiUrl || '', + gs1BrasilAtivo: config.gs1BrasilAtivo, + // Bluesoft Cosmo + bluesoftApiKey: '********', // Mascarar + bluesoftApiUrl: config.bluesoftApiUrl || '', + bluesoftAtivo: config.bluesoftAtivo, + // Product-Search.net + productSearchApiKey: '********', // Mascarar + productSearchApiUrl: config.productSearchApiUrl || '', + productSearchAtivo: config.productSearchAtivo, + ativo: config.ativo, + atualizadoEm: config.atualizadoEm + }; + } +}); + +/** + * Salvar configuração de busca de código de barras + */ +export const salvarConfigBuscaCodigoBarras = mutation({ + args: { + // GS1 Brasil + gs1BrasilClientId: v.optional(v.string()), + gs1BrasilClientSecret: v.optional(v.string()), + gs1BrasilTokenUrl: v.optional(v.string()), + gs1BrasilApiUrl: v.optional(v.string()), + gs1BrasilAtivo: v.boolean(), + // Bluesoft Cosmo + bluesoftApiKey: v.optional(v.string()), + bluesoftApiUrl: v.optional(v.string()), + bluesoftAtivo: v.boolean(), + // Product-Search.net + productSearchApiKey: v.optional(v.string()), + productSearchApiUrl: v.optional(v.string()), + productSearchAtivo: v.boolean(), + configuradoPorId: v.id('usuarios') + }, + returns: v.union( + v.object({ sucesso: v.literal(true), configId: v.id('configuracaoBuscaCodigoBarras') }), + v.object({ sucesso: v.literal(false), erro: v.string() }) + ), + handler: async (ctx, args) => { + // Buscar config ativa anterior para manter credenciais se não fornecidas + const configAtiva = await ctx.db + .query('configuracaoBuscaCodigoBarras') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + // Determinar credenciais: usar novas se fornecidas, senão manter as atuais + let gs1BrasilClientSecret: string | undefined; + if (args.gs1BrasilClientSecret && args.gs1BrasilClientSecret.trim().length > 0) { + // Nova credencial fornecida, criptografar + gs1BrasilClientSecret = await encryptSMTPPassword(args.gs1BrasilClientSecret); + } else if (configAtiva?.gs1BrasilClientSecret) { + // Credencial não fornecida, manter a atual + gs1BrasilClientSecret = configAtiva.gs1BrasilClientSecret; + } + + let bluesoftApiKey: string | undefined; + if (args.bluesoftApiKey && args.bluesoftApiKey.trim().length > 0) { + bluesoftApiKey = await encryptSMTPPassword(args.bluesoftApiKey); + } else if (configAtiva?.bluesoftApiKey) { + bluesoftApiKey = configAtiva.bluesoftApiKey; + } + + let productSearchApiKey: string | undefined; + if (args.productSearchApiKey && args.productSearchApiKey.trim().length > 0) { + productSearchApiKey = await encryptSMTPPassword(args.productSearchApiKey); + } else if (configAtiva?.productSearchApiKey) { + productSearchApiKey = configAtiva.productSearchApiKey; + } + + // Desativar config anterior + const configsAntigas = await ctx.db + .query('configuracaoBuscaCodigoBarras') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + + for (const config of configsAntigas) { + await ctx.db.patch(config._id, { ativo: false }); + } + + // Criar nova config + const configId = await ctx.db.insert('configuracaoBuscaCodigoBarras', { + gs1BrasilClientId: args.gs1BrasilClientId || undefined, + gs1BrasilClientSecret, + gs1BrasilTokenUrl: args.gs1BrasilTokenUrl || undefined, + gs1BrasilApiUrl: args.gs1BrasilApiUrl || undefined, + gs1BrasilAtivo: args.gs1BrasilAtivo, + bluesoftApiKey, + bluesoftApiUrl: args.bluesoftApiUrl || undefined, + bluesoftAtivo: args.bluesoftAtivo, + productSearchApiKey, + productSearchApiUrl: args.productSearchApiUrl || undefined, + productSearchAtivo: args.productSearchAtivo, + ativo: true, + configuradoPor: args.configuradoPorId, + atualizadoEm: Date.now() + }); + + // Log de atividade + await registrarAtividade( + ctx, + args.configuradoPorId, + 'configurar', + 'buscaCodigoBarras', + JSON.stringify({ + gs1BrasilAtivo: args.gs1BrasilAtivo, + bluesoftAtivo: args.bluesoftAtivo, + productSearchAtivo: args.productSearchAtivo + }), + configId + ); + + return { sucesso: true as const, configId }; + } +}); + +/** + * Obter configuração de busca de código de barras (internal query) + * Usado pela action de busca para obter credenciais descriptografadas + */ +export const obterConfigBuscaCodigoBarrasInternal = internalQuery({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query('configuracaoBuscaCodigoBarras') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + return null; + } + + // Retornar config completa (para uso interno) + return config; + } +}); + diff --git a/packages/backend/convex/configuracaoRelogio.ts b/packages/backend/convex/configuracaoRelogio.ts index 1aff1b6..8426b21 100644 --- a/packages/backend/convex/configuracaoRelogio.ts +++ b/packages/backend/convex/configuracaoRelogio.ts @@ -279,8 +279,8 @@ export const sincronizarTempo = action({ // Sempre usar fallback como última opção, mesmo se desabilitado // Isso evita que o sistema trave completamente se o servidor externo não estiver disponível const aviso: string = config.fallbackParaPC - ? 'Falha ao sincronizar com servidor externo, usando relógio do PC' - : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.'; + ? 'Falha ao sincronizar com servidor externo, usando servidor interno' + : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando servidor interno como última opção.'; console.warn('Erro ao sincronizar tempo com servidor externo:', error); 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/pontos.ts b/packages/backend/convex/pontos.ts index 7b7f488..332a0b0 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -605,6 +605,15 @@ export const registrarPonto = mutation({ const dia = String(dataObj.getUTCDate()).padStart(2, '0'); const data = `${ano}-${mes}-${dia}`; + // Bloquear registro de ponto quando houver ausência aprovada ativa na data + const ausenciaInfo = await verificarAusenciaAprovada(ctx, usuario.funcionarioId, data); + if (ausenciaInfo.temAusencia) { + throw new Error( + ausenciaInfo.motivo || + 'Não é possível registrar ponto: existe uma ausência aprovada ativa para esta data.' + ); + } + // Verificar se já existe registro no mesmo minuto const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined const registrosMinuto = await ctx.db 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..6ccb6b5 --- /dev/null +++ b/packages/backend/convex/tables/almoxarifado.ts @@ -0,0 +1,154 @@ +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()), + codigoBarras: v.optional(v.string()), + imagemUrl: v.optional(v.string()), + imagemBase64: 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']) + .index('by_codigoBarras', ['codigoBarras']), + + 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()), + reprovadoPor: v.optional(v.id('funcionarios')), + dataReprovacao: v.optional(v.number()), + motivoReprovacao: v.optional(v.string()), + 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() + }).index('by_ativo', ['ativo']) +}; \ No newline at end of file diff --git a/packages/backend/convex/tables/system.ts b/packages/backend/convex/tables/system.ts index 37147c4..bf9c26c 100644 --- a/packages/backend/convex/tables/system.ts +++ b/packages/backend/convex/tables/system.ts @@ -242,5 +242,29 @@ export const systemTables = { .index('by_status_code', ['statusCode']) .index('by_notificado', ['notificado']) .index('by_criado_em', ['criadoEm']) - .index('by_usuario', ['usuarioId']) + .index('by_usuario', ['usuarioId']), + + // Configuração de APIs de Busca de Código de Barras + configuracaoBuscaCodigoBarras: defineTable({ + // GS1 Brasil + gs1BrasilClientId: v.optional(v.string()), + gs1BrasilClientSecret: v.optional(v.string()), + gs1BrasilTokenUrl: v.optional(v.string()), + gs1BrasilApiUrl: v.optional(v.string()), + gs1BrasilAtivo: v.boolean(), + + // Bluesoft Cosmo + bluesoftApiKey: v.optional(v.string()), + bluesoftApiUrl: v.optional(v.string()), + bluesoftAtivo: v.boolean(), + + // Product-Search.net + productSearchApiKey: v.optional(v.string()), + productSearchApiUrl: v.optional(v.string()), + productSearchAtivo: v.boolean(), + + ativo: v.boolean(), + configuradoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['ativo']) }; diff --git a/packages/backend/convex/templatesMensagens.ts b/packages/backend/convex/templatesMensagens.ts index 9b45ec0..9d0aafc 100644 --- a/packages/backend/convex/templatesMensagens.ts +++ b/packages/backend/convex/templatesMensagens.ts @@ -1005,6 +1005,177 @@ export const criarTemplatesPadrao = mutation({ ], categoria: 'email' as const, tags: ['erro', '500', 'servidor', 'critico', 'notificacao', 'ti'] + }, + // ===================== ALMOXARIFADO - ALERTAS ===================== + { + codigo: 'almoxarifado_alerta_criado', + nome: 'Almoxarifado - Alerta de Estoque Criado', + titulo: '⚠️ Alerta de Estoque: {{materialNome}}', + corpo: + 'Olá,\n\n' + + 'Um novo alerta de estoque foi criado no sistema:\n\n' + + 'Material: {{materialNome}}\n' + + 'Código: {{materialCodigo}}\n' + + 'Tipo de Alerta: {{tipoAlerta}}\n' + + 'Quantidade Atual: {{quantidadeAtual}} {{unidadeMedida}}\n' + + 'Quantidade Mínima: {{quantidadeMinima}} {{unidadeMedida}}\n' + + 'Diferença: {{diferenca}} {{unidadeMedida}}\n\n' + + 'Por favor, verifique o estoque e realize a reposição necessária.', + htmlCorpo: + '
' + + '
' + + '

⚠️ Alerta de Estoque

' + + '

Material: {{materialNome}}

' + + '
' + + '

Olá,

' + + '

Um novo alerta de estoque foi criado no sistema:

' + + '
' + + '

📦 Informações do Material:

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Material:{{materialNome}}
Código:{{materialCodigo}}
Tipo de Alerta:{{tipoAlerta}}
Quantidade Atual:{{quantidadeAtual}} {{unidadeMedida}}
Quantidade Mínima:{{quantidadeMinima}} {{unidadeMedida}}
Diferença:{{diferenca}} {{unidadeMedida}}
' + + '
' + + '
' + + '

' + + '💡 Ação Necessária: Por favor, verifique o estoque e realize a reposição necessária.' + + '

' + + '
' + + '

' + + 'Ver Alertas' + + '

' + + '

' + + 'Almoxarifado SGSE - Sistema de Gerenciamento de Secretaria' + + '

' + + '
', + variaveis: [ + 'materialNome', + 'materialCodigo', + 'tipoAlerta', + 'quantidadeAtual', + 'quantidadeMinima', + 'unidadeMedida', + 'diferenca', + 'urlSistema' + ], + categoria: 'email' as const, + tags: ['almoxarifado', 'alerta', 'estoque', 'reposicao'] + }, + { + codigo: 'almoxarifado_alerta_resolvido', + nome: 'Almoxarifado - Alerta de Estoque Resolvido', + titulo: '✅ Alerta de Estoque Resolvido: {{materialNome}}', + corpo: + 'Olá,\n\n' + + 'O alerta de estoque abaixo foi resolvido:\n\n' + + 'Material: {{materialNome}}\n' + + 'Código: {{materialCodigo}}\n' + + 'Quantidade Atual: {{quantidadeAtual}} {{unidadeMedida}}\n' + + 'Quantidade Mínima: {{quantidadeMinima}} {{unidadeMedida}}\n' + + 'Resolvido por: {{resolvidoPor}}\n' + + 'Data: {{dataResolucao}}\n\n' + + 'O estoque está agora acima do mínimo configurado.', + htmlCorpo: + '
' + + '
' + + '

✅ Alerta Resolvido

' + + '

Material: {{materialNome}}

' + + '
' + + '

Olá,

' + + '

O alerta de estoque abaixo foi resolvido:

' + + '
' + + '

📦 Informações do Material:

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Material:{{materialNome}}
Código:{{materialCodigo}}
Quantidade Atual:{{quantidadeAtual}} {{unidadeMedida}}
Quantidade Mínima:{{quantidadeMinima}} {{unidadeMedida}}
Resolvido por:{{resolvidoPor}}
Data:{{dataResolucao}}
' + + '
' + + '
' + + '

✅ O estoque está agora acima do mínimo configurado.

' + + '
' + + '

' + + 'Ver Alertas' + + '

' + + '

' + + 'Almoxarifado SGSE - Sistema de Gerenciamento de Secretaria' + + '

' + + '
', + variaveis: [ + 'materialNome', + 'materialCodigo', + 'quantidadeAtual', + 'quantidadeMinima', + 'unidadeMedida', + 'resolvidoPor', + 'dataResolucao', + 'urlSistema' + ], + categoria: 'email' as const, + tags: ['almoxarifado', 'alerta', 'estoque', 'resolvido'] + }, + { + codigo: 'almoxarifado_alerta_ignorado', + nome: 'Almoxarifado - Alerta de Estoque Ignorado', + titulo: '⚠️ Alerta de Estoque Ignorado: {{materialNome}}', + corpo: + 'Olá,\n\n' + + 'O alerta de estoque abaixo foi ignorado:\n\n' + + 'Material: {{materialNome}}\n' + + 'Código: {{materialCodigo}}\n' + + 'Tipo de Alerta: {{tipoAlerta}}\n' + + 'Quantidade Atual: {{quantidadeAtual}} {{unidadeMedida}}\n' + + 'Quantidade Mínima: {{quantidadeMinima}} {{unidadeMedida}}\n' + + 'Data: {{dataIgnorado}}\n\n' + + '⚠️ Atenção: O estoque ainda está abaixo do mínimo configurado.', + htmlCorpo: + '
' + + '
' + + '

⚠️ Alerta Ignorado

' + + '

Material: {{materialNome}}

' + + '
' + + '

Olá,

' + + '

O alerta de estoque abaixo foi ignorado:

' + + '
' + + '

📦 Informações do Material:

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Material:{{materialNome}}
Código:{{materialCodigo}}
Tipo de Alerta:{{tipoAlerta}}
Quantidade Atual:{{quantidadeAtual}} {{unidadeMedida}}
Quantidade Mínima:{{quantidadeMinima}} {{unidadeMedida}}
Data:{{dataIgnorado}}
' + + '
' + + '
' + + '

⚠️ Atenção: O estoque ainda está abaixo do mínimo configurado.

' + + '
' + + '

' + + 'Ver Alertas' + + '

' + + '

' + + 'Almoxarifado SGSE - Sistema de Gerenciamento de Secretaria' + + '

' + + '
', + variaveis: [ + 'materialNome', + 'materialCodigo', + 'tipoAlerta', + 'quantidadeAtual', + 'quantidadeMinima', + 'unidadeMedida', + 'dataIgnorado', + 'urlSistema' + ], + categoria: 'email' as const, + tags: ['almoxarifado', 'alerta', 'estoque', 'ignorado'] } ];