From f0884a19a76887c4de032c0778fed49931f63224 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sun, 21 Dec 2025 08:02:14 -0300 Subject: [PATCH] feat: implement email notification system for 'Almoxarifado' alerts, enhancing user awareness of stock levels and alert statuses through automated email updates --- .../almoxarifado/alertas/+page.svelte | 40 +++- .../almoxarifado/requisicoes/+page.svelte | 68 ++++++- packages/backend/convex/almoxarifado.ts | 142 ++++++++++++++- .../convex/configuracaoAlmoxarifado.ts | 13 +- packages/backend/convex/templatesMensagens.ts | 171 ++++++++++++++++++ 5 files changed, 419 insertions(+), 15 deletions(-) diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/alertas/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/alertas/+page.svelte index 3a82f52..a622f72 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/alertas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/alertas/+page.svelte @@ -9,7 +9,7 @@ const client = useConvexClient(); let filtroTipo = $state(''); - let filtroStatus = $state('ativo'); + let filtroStatus = $state(''); const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: filtroStatus ? (filtroStatus as AlertaStatus) : undefined, @@ -17,6 +17,16 @@ }); const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {}); + // Criar mapa de materiais para lookup eficiente + const materiaisMap = $derived.by(() => { + if (!materiaisQuery.data) return new Map(); + const map = new Map(); + for (const material of materiaisQuery.data) { + map.set(material._id, material); + } + return map; + }); + let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null); function mostrarMensagem(kind: 'success' | 'error', text: string) { @@ -145,7 +155,11 @@
- {#if alertasQuery.data && alertasQuery.data.length > 0} + {#if alertasQuery === undefined} +
+ +
+ {:else if alertasQuery.data && alertasQuery.data.length > 0}
@@ -162,7 +176,7 @@ {#each alertasQuery.data as alerta} - {@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)} + {@const material = materiaisMap.get(alerta.materialId)} {@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual}
@@ -222,15 +236,29 @@ {:else}
- +

Nenhum alerta encontrado

-

+

{#if filtroStatus === 'ativo'} Não há alertas ativos no momento. Todos os materiais estão com estoque adequado! - {:else} + {: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} diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/requisicoes/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/requisicoes/+page.svelte index cd4495d..63c0483 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/requisicoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/requisicoes/+page.svelte @@ -4,6 +4,7 @@ import { useConvexClient, useQuery } from 'convex-svelte'; import { resolve } from '$app/paths'; import { ClipboardList, Plus, CheckCircle, XCircle, Package } from 'lucide-svelte'; + import ErrorModal from '$lib/components/ErrorModal.svelte'; const client = useConvexClient(); @@ -34,12 +35,60 @@ const setoresQuery = useQuery(api.setores.list, {}); let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null); + let showErrorModal = $state(false); + let errorModalTitle = $state('Erro ao processar requisição'); + let errorModalMessage = $state(''); + let errorModalDetails = $state(''); function mostrarMensagem(kind: 'success' | 'error', text: string) { - notice = { kind, text }; - setTimeout(() => { - notice = null; - }, 5000); + if (kind === 'error') { + // Para erros, usar modal + errorModalTitle = 'Erro ao processar requisição'; + + // Extrair mensagem de erro amigável + let mensagemAmigavel = text; + let detalhesTecnicos = ''; + + // Remover prefixos técnicos do Convex se existirem + if (text.includes('[CONVEX') || text.includes('Request ID')) { + // Extrair apenas a parte da mensagem de erro após o último ']' + const partes = text.split(']'); + if (partes.length > 1) { + mensagemAmigavel = partes[partes.length - 1].trim(); + } + detalhesTecnicos = 'Detalhes técnicos:\n' + text; + } + + // Melhorar mensagens específicas + if (mensagemAmigavel.includes('Funcionário não encontrado para o usuário')) { + errorModalTitle = 'Erro: Funcionário não encontrado'; + mensagemAmigavel = 'Não foi possível encontrar o funcionário associado ao seu usuário.'; + detalhesTecnicos = 'Por favor, entre em contato com o suporte técnico.\n\n' + + '1. Verifique se o seu usuário está associado a um funcionário no sistema\n' + + '2. Solicite a associação do seu usuário a um funcionário\n' + + '3. Entre em contato com a equipe de TI se o problema persistir\n\n' + + 'Detalhes técnicos:\n' + text; + } else if (mensagemAmigavel.includes('não encontrado')) { + mensagemAmigavel = mensagemAmigavel.replace(/não encontrado/gi, 'não foi encontrado no sistema'); + } + + errorModalMessage = mensagemAmigavel; + errorModalDetails = detalhesTecnicos; + + showErrorModal = true; + } else { + // Para sucesso, usar banner simples + notice = { kind, text }; + setTimeout(() => { + notice = null; + }, 5000); + } + } + + function fecharErrorModal() { + showErrorModal = false; + errorModalMessage = ''; + errorModalDetails = ''; } $effect(() => { @@ -216,13 +265,22 @@ - + {#if notice}
{notice.text}
{/if} + + +
diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts index 2cfda8a..e34e26b 100644 --- a/packages/backend/convex/almoxarifado.ts +++ b/packages/backend/convex/almoxarifado.ts @@ -3,6 +3,7 @@ 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, @@ -376,6 +377,116 @@ async function registrarHistorico( }); } +/** + * 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; @@ -401,7 +512,7 @@ async function verificarECriarAlerta(ctx: MutationCtx, materialId: Id<'materiais } // Criar alerta - await ctx.db.insert('alertasEstoque', { + const alertaId = await ctx.db.insert('alertasEstoque', { materialId, tipo, quantidadeAtual: material.estoqueAtual, @@ -409,6 +520,13 @@ async function verificarECriarAlerta(ctx: MutationCtx, materialId: Id<'materiais 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'>) { @@ -979,12 +1097,23 @@ export const resolverAlerta = mutation({ 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' @@ -1003,10 +1132,21 @@ export const ignorarAlerta = mutation({ 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' diff --git a/packages/backend/convex/configuracaoAlmoxarifado.ts b/packages/backend/convex/configuracaoAlmoxarifado.ts index e266dee..17a4904 100644 --- a/packages/backend/convex/configuracaoAlmoxarifado.ts +++ b/packages/backend/convex/configuracaoAlmoxarifado.ts @@ -71,10 +71,17 @@ export const atualizarConfiguracao = mutation({ // Desativar configuração antiga await ctx.db.patch(config._id, { ativo: false }); - // Criar nova configuração + // Criar nova configuração (sem incluir _id e campos de sistema) const dadosNovos = { - ...config, - ...args, + 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() 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'] } ];