feat: add tab navigation and content management for notifications page, allowing users to switch between Enviar Notificação, Gerenciar Templates, and Agendamentos for improved organization and usability

This commit is contained in:
2025-11-30 16:33:52 -03:00
parent 2fb7df8849
commit 4ab151bed7
11 changed files with 1370 additions and 57 deletions

View File

@@ -135,6 +135,9 @@
let criandoTemplates = $state(false);
let progressoEnvio = $state({ total: 0, enviados: 0, falhas: 0 });
// Aba ativa
let abaAtiva = $state<'enviar' | 'templates' | 'agendamentos'>('enviar');
// Estrutura de dados para logs de envio
type StatusLog = 'sucesso' | 'erro' | 'fila' | 'info' | 'enviando';
type TipoLog = 'chat' | 'email';
@@ -1173,6 +1176,36 @@
</div>
{/if}
<!-- Abas de Navegação -->
<div class="tabs tabs-boxed mb-6">
<button
type="button"
class="tab"
class:tab-active={abaAtiva === 'enviar'}
onclick={() => (abaAtiva = 'enviar')}
>
Enviar Notificação
</button>
<button
type="button"
class="tab"
class:tab-active={abaAtiva === 'templates'}
onclick={() => (abaAtiva = 'templates')}
>
Gerenciar Templates
</button>
<button
type="button"
class="tab"
class:tab-active={abaAtiva === 'agendamentos'}
onclick={() => (abaAtiva = 'agendamentos')}
>
Agendamentos
</button>
</div>
<!-- Conteúdo da Aba: Enviar Notificação -->
{#if abaAtiva === 'enviar'}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl">
@@ -1645,7 +1678,28 @@
</div>
</div>
</div>
{/if}
<!-- Conteúdo da Aba: Templates -->
{#if abaAtiva === 'templates'}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<h2 class="card-title">Templates de Mensagens</h2>
<a href="/ti/notificacoes/templates" class="btn btn-primary">
Gerenciar Templates
</a>
</div>
<p class="text-base-content/60">
Acesse a página de gerenciamento de templates para criar, editar e excluir templates de emails e
mensagens.
</p>
</div>
</div>
{/if}
<!-- Conteúdo da Aba: Agendamentos -->
{#if abaAtiva === 'agendamentos'}
<!-- Histórico de Agendamentos -->
<div class="card bg-base-100 mt-6 shadow-xl">
<div class="card-body">
@@ -1864,6 +1918,7 @@
{/if}
</div>
</div>
{/if}
<!-- Info -->
<div class="alert alert-warning mt-6">

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { FunctionReference } from 'convex/server';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
// Queries
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
const templates = $derived.by(() => {
if (templatesQuery === undefined || templatesQuery === null) {
return [];
}
if ('data' in templatesQuery && templatesQuery.data !== undefined) {
return Array.isArray(templatesQuery.data) ? templatesQuery.data : [];
}
if (Array.isArray(templatesQuery)) {
return templatesQuery;
}
return [];
});
// Estados
let templateEditando = $state<Doc<'templatesMensagens'> | null>(null);
let modalAberto = $state(false);
let filtroCategoria = $state<'todos' | 'email' | 'chat' | 'ambos'>('todos');
let buscaTexto = $state('');
let processando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error' | 'info'; texto: string } | null>(null);
// Filtrar templates
const templatesFiltrados = $derived.by(() => {
let filtrados = templates;
// Filtro por categoria
if (filtroCategoria !== 'todos') {
filtrados = filtrados.filter((t) => t.categoria === filtroCategoria);
}
// Filtro por busca
if (buscaTexto.trim()) {
const busca = buscaTexto.toLowerCase();
filtrados = filtrados.filter(
(t) =>
t.nome.toLowerCase().includes(busca) ||
t.codigo.toLowerCase().includes(busca) ||
t.titulo.toLowerCase().includes(busca)
);
}
return filtrados;
});
function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
mensagem = null;
}, 5000);
}
async function excluirTemplate(templateId: Id<'templatesMensagens'>) {
if (!currentUser.data) return;
if (!confirm('Tem certeza que deseja excluir este template?')) return;
try {
processando = true;
const resultado = await client.mutation(api.templatesMensagens.excluirTemplate, {
templateId,
excluidoPorId: currentUser.data._id,
});
if (resultado.sucesso) {
mostrarMensagem('success', 'Template excluído com sucesso!');
} else {
mostrarMensagem('error', resultado.erro || 'Erro ao excluir template');
}
} catch (error) {
const erro = error instanceof Error ? error.message : 'Erro desconhecido';
mostrarMensagem('error', `Erro ao excluir template: ${erro}`);
} finally {
processando = false;
}
}
function abrirModalEdicao(template: Doc<'templatesMensagens'>) {
templateEditando = template;
modalAberto = true;
}
function fecharModal() {
modalAberto = false;
templateEditando = null;
}
</script>
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="bg-info/10 rounded-xl p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-info h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div>
<h1 class="text-base-content text-3xl font-bold">Gerenciar Templates</h1>
<p class="text-base-content/60 mt-1">Criar, editar e excluir templates de emails e mensagens</p>
</div>
</div>
<a href="/ti/notificacoes" class="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</a>
</div>
<!-- Mensagens de Feedback -->
{#if mensagem}
<div
class="alert mb-6 shadow-lg"
class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === 'error'}
class:alert-info={mensagem.tipo === 'info'}
>
<span class="font-medium">{mensagem.texto}</span>
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={() => (mensagem = null)}>
</button>
</div>
{/if}
<!-- Filtros e Busca -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Buscar</span>
</label>
<input
type="text"
bind:value={buscaTexto}
placeholder="Buscar por nome, código ou título..."
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Categoria</span>
</label>
<select bind:value={filtroCategoria} class="select select-bordered">
<option value="todos">Todas</option>
<option value="email">Email</option>
<option value="chat">Chat</option>
<option value="ambos">Ambos</option>
</select>
</div>
</div>
</div>
</div>
<!-- Lista de Templates -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<h2 class="card-title">Templates ({templatesFiltrados.length})</h2>
<a href="/ti/notificacoes/templates/novo" class="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Novo Template
</a>
</div>
{#if templatesFiltrados.length === 0}
<div class="py-10 text-center">
<p class="text-base-content/60">Nenhum template encontrado.</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Código</th>
<th>Nome</th>
<th>Tipo</th>
<th>Categoria</th>
<th>Variáveis</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each templatesFiltrados as template (template._id)}
<tr>
<td>
<code class="badge badge-ghost">{template.codigo}</code>
</td>
<td>
<div class="font-medium">{template.nome}</div>
<div class="text-sm text-base-content/60">{template.titulo}</div>
</td>
<td>
{#if template.tipo === 'sistema'}
<span class="badge badge-info">Sistema</span>
{:else}
<span class="badge badge-success">Customizado</span>
{/if}
</td>
<td>
{#if template.categoria}
<span class="badge badge-outline">{template.categoria}</span>
{:else}
<span class="badge badge-ghost">-</span>
{/if}
</td>
<td>
{#if template.variaveis && template.variaveis.length > 0}
<div class="flex flex-wrap gap-1">
{#each template.variaveis.slice(0, 3) as var}
<span class="badge badge-sm">{{var}}</span>
{/each}
{#if template.variaveis.length > 3}
<span class="badge badge-sm">+{template.variaveis.length - 3}</span>
{/if}
</div>
{:else}
<span class="text-base-content/40">-</span>
{/if}
</td>
<td>
<div class="flex gap-2">
<a
href="/ti/notificacoes/templates/{template._id}"
class="btn btn-sm btn-ghost"
title="Editar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</a>
{#if template.tipo === 'customizado'}
<button
type="button"
class="btn btn-sm btn-ghost text-error"
onclick={() => excluirTemplate(template._id)}
disabled={processando}
title="Excluir"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -57,7 +57,10 @@ import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
import type * as utils_getClientIP from "../utils/getClientIP.js";
import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
import type {
@@ -116,7 +119,10 @@ declare const fullApi: ApiFromModules<{
times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
"utils/getClientIP": typeof utils_getClientIP;
"utils/scanEmailSenders": typeof utils_scanEmailSenders;
verificarMatriculas: typeof verificarMatriculas;
}>;

View File

@@ -358,20 +358,45 @@ export const criarSolicitacao = mutation({
.first();
if (gestorUsuario && funcionarioUsuario) {
// Enviar email ao gestor
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
corpo: `<p>Olá ${gestorUsuario.nome},</p>
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
<ul>
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${args.motivo}</li>
</ul>
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
enviadoPor: funcionarioUsuario._id,
});
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Enviar email ao gestor usando template
try {
await ctx.runAction(api.email.enviarEmailComTemplate, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
templateCodigo: "ausencia_solicitada",
variaveis: {
gestorNome: gestorUsuario.nome,
funcionarioNome: funcionario.nome,
dataInicio: new Date(args.dataInicio).toLocaleDateString("pt-BR"),
dataFim: new Date(args.dataFim).toLocaleDateString("pt-BR"),
motivo: args.motivo,
urlSistema,
},
enviadoPor: funcionarioUsuario._id,
});
} catch (error) {
// Fallback para envio direto se template não existir
console.warn("Template ausencia_solicitada não encontrado, usando envio direto:", error);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
corpo: `<p>Olá ${gestorUsuario.nome},</p>
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
<ul>
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${args.motivo}</li>
</ul>
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
enviadoPor: funcionarioUsuario._id,
});
}
// Criar ou obter conversa entre gestor e funcionário
const conversasExistentes = await ctx.db
@@ -475,19 +500,44 @@ export const aprovar = mutation({
const gestorUsuario = await ctx.db.get(args.gestorId);
if (gestorUsuario) {
// Enviar email ao funcionário
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Aprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
</ul>`,
enviadoPor: args.gestorId,
});
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Enviar email ao funcionário usando template
try {
await ctx.runAction(api.email.enviarEmailComTemplate, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
templateCodigo: "ausencia_aprovada",
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"),
motivo: solicitacao.motivo,
urlSistema,
},
enviadoPor: args.gestorId,
});
} catch (error) {
// Fallback para envio direto se template não existir
console.warn("Template ausencia_aprovada não encontrado, usando envio direto:", error);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Aprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
</ul>`,
enviadoPor: args.gestorId,
});
}
// Criar ou obter conversa
const conversasExistentes = await ctx.db
@@ -593,20 +643,46 @@ export const reprovar = mutation({
const gestorUsuario = await ctx.db.get(args.gestorId);
if (gestorUsuario) {
// Enviar email ao funcionário
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Reprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
</ul>`,
enviadoPor: args.gestorId,
});
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Enviar email ao funcionário usando template
try {
await ctx.runAction(api.email.enviarEmailComTemplate, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
templateCodigo: "ausencia_reprovada",
variaveis: {
funcionarioNome: funcionarioUsuario.nome,
gestorNome: gestorUsuario.nome,
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR"),
dataFim: new Date(solicitacao.dataFim).toLocaleDateString("pt-BR"),
motivo: solicitacao.motivo,
motivoReprovacao: args.motivoReprovacao,
urlSistema,
},
enviadoPor: args.gestorId,
});
} catch (error) {
// Fallback para envio direto se template não existir
console.warn("Template ausencia_reprovada não encontrado, usando envio direto:", error);
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Reprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
</ul>`,
enviadoPor: args.gestorId,
});
}
// Criar ou obter conversa
const conversasExistentes = await ctx.db

View File

@@ -121,15 +121,38 @@ async function registrarNotificacoes(
) {
const { ticket, titulo, mensagem, usuarioEvento } = params;
// Obter URL do sistema
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
// Notificar solicitante
if (ticket.solicitanteEmail) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail,
destinatarioId: ticket.solicitanteId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
// Tentar usar template, senão usar envio direto
try {
await ctx.runAction(api.email.enviarEmailComTemplate, {
destinatario: ticket.solicitanteEmail,
destinatarioId: ticket.solicitanteId,
templateCodigo: "chamado_atualizado",
variaveis: {
solicitante: ticket.solicitanteNome || "Usuário",
numeroTicket: ticket.numero,
mensagem: mensagem,
urlSistema,
},
enviadoPor: usuarioEvento,
});
} catch (error) {
// Fallback para envio direto
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail,
destinatarioId: ticket.solicitanteId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
}
}
await ctx.db.insert("notificacoes", {
@@ -147,13 +170,30 @@ async function registrarNotificacoes(
if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) {
const responsavel = await ctx.db.get(ticket.responsavelId);
if (responsavel?.email) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
// Tentar usar template, senão usar envio direto
try {
await ctx.runAction(api.email.enviarEmailComTemplate, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
templateCodigo: "chamado_atualizado",
variaveis: {
solicitante: ticket.solicitanteNome || "Usuário",
numeroTicket: ticket.numero,
mensagem: mensagem,
urlSistema,
},
enviadoPor: usuarioEvento,
});
} catch (error) {
// Fallback para envio direto
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: responsavel.email,
destinatarioId: ticket.responsavelId,
assunto: `${titulo} - Chamado ${ticket.numero}`,
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
enviadoPor: usuarioEvento,
});
}
}
await ctx.db.insert("notificacoes", {

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
import { internal, api } from "./_generated/api";
import { renderizarTemplate } from "./templatesMensagens";
import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper";
import type { Doc, Id } from "./_generated/dataModel";
// ========== INTERNAL QUERIES ==========
@@ -221,12 +222,27 @@ export const enviarEmailComTemplate = action({
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
const corpoRenderizado = renderizarTemplate(template.corpo, variaveisTemplate);
// Usar htmlCorpo se disponível, senão gerar do corpo
let corpoHTML = template.htmlCorpo;
if (corpoHTML) {
// Renderizar variáveis no HTML
corpoHTML = renderizarTemplate(corpoHTML, variaveisTemplate);
} else {
// Gerar HTML do corpo renderizado
if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) {
corpoHTML = wrapEmailHTML(corpoRenderizado, tituloRenderizado);
} else {
const corpoHTMLFormatado = textToHTML(corpoRenderizado);
corpoHTML = wrapEmailHTML(corpoHTMLFormatado, tituloRenderizado);
}
}
// Enfileirar email via mutation
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: tituloRenderizado,
corpo: corpoRenderizado,
corpo: corpoHTML, // Usar HTML completo
templateId: template._id, // template._id sempre existe se template não é null
enviadoPor: args.enviadoPor,
agendadaPara: args.agendadaPara,

View File

@@ -851,13 +851,19 @@ export default defineSchema({
),
titulo: v.string(),
corpo: v.string(), // pode ter variáveis {{variavel}}
htmlCorpo: v.optional(v.string()), // versão HTML do corpo (com wrapper)
variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.]
categoria: v.optional(
v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))
), // categoria do template
tags: v.optional(v.array(v.string())), // tags para organização
criadoPor: v.optional(v.id("usuarios")),
criadoEm: v.number(),
})
.index("by_codigo", ["codigo"])
.index("by_tipo", ["tipo"])
.index("by_criado_por", ["criadoPor"]),
.index("by_criado_por", ["criadoPor"])
.index("by_categoria", ["categoria"]),
// Configuração de Email/SMTP
configuracaoEmail: defineTable({

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
import { Doc } from "./_generated/dataModel";
import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper";
/**
* Listar todos os templates
@@ -40,7 +41,10 @@ export const criarTemplate = mutation({
nome: v.string(),
titulo: v.string(),
corpo: v.string(),
htmlCorpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))),
tags: v.optional(v.array(v.string())),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
@@ -58,6 +62,18 @@ export const criarTemplate = mutation({
return { sucesso: false as const, erro: "Código de template já existe" };
}
// Gerar HTML se não fornecido
let htmlCorpo = args.htmlCorpo;
if (!htmlCorpo) {
// Se o corpo já for HTML, usar diretamente, senão converter
if (args.corpo.includes("<") && args.corpo.includes(">")) {
htmlCorpo = wrapEmailHTML(args.corpo, args.titulo);
} else {
const corpoHTML = textToHTML(args.corpo);
htmlCorpo = wrapEmailHTML(corpoHTML, args.titulo);
}
}
// Criar template
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.codigo,
@@ -65,7 +81,10 @@ export const criarTemplate = mutation({
tipo: "customizado",
titulo: args.titulo,
corpo: args.corpo,
htmlCorpo,
variaveis: args.variaveis,
categoria: args.categoria || "email",
tags: args.tags,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
@@ -93,7 +112,10 @@ export const editarTemplate = mutation({
nome: v.optional(v.string()),
titulo: v.optional(v.string()),
corpo: v.optional(v.string()),
htmlCorpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))),
tags: v.optional(v.array(v.string())),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
@@ -116,7 +138,21 @@ export const editarTemplate = mutation({
if (args.nome !== undefined) updates.nome = args.nome;
if (args.titulo !== undefined) updates.titulo = args.titulo;
if (args.corpo !== undefined) updates.corpo = args.corpo;
if (args.htmlCorpo !== undefined) {
updates.htmlCorpo = args.htmlCorpo;
} else if (args.corpo !== undefined) {
// Se corpo foi atualizado mas htmlCorpo não, regenerar HTML
const titulo = args.titulo || template.titulo;
if (args.corpo.includes("<") && args.corpo.includes(">")) {
updates.htmlCorpo = wrapEmailHTML(args.corpo, titulo);
} else {
const corpoHTML = textToHTML(args.corpo);
updates.htmlCorpo = wrapEmailHTML(corpoHTML, titulo);
}
}
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
if (args.categoria !== undefined) updates.categoria = args.categoria;
if (args.tags !== undefined) updates.tags = args.tags;
await ctx.db.patch(args.templateId, updates);
@@ -396,6 +432,33 @@ export const criarTemplatesPadrao = mutation({
+ "</div></body></html>",
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
},
{
codigo: "ausencia_solicitada",
nome: "Ausência Solicitada",
titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo", "urlSistema"],
categoria: "email" as const,
tags: ["ausencia", "solicitacao", "gestao"],
},
{
codigo: "ausencia_aprovada",
nome: "Ausência Aprovada",
titulo: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "urlSistema"],
categoria: "email" as const,
tags: ["ausencia", "aprovacao", "gestao"],
},
{
codigo: "ausencia_reprovada",
nome: "Ausência Reprovada",
titulo: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao", "urlSistema"],
categoria: "email" as const,
tags: ["ausencia", "reprovacao", "gestao"],
},
];
for (const template of templatesPadrao) {
@@ -418,4 +481,321 @@ export const criarTemplatesPadrao = mutation({
},
});
/**
* Atualizar HTML de um template
*/
export const atualizarTemplateHTML = mutation({
args: {
templateId: v.id("templatesMensagens"),
htmlCorpo: v.string(),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite editar templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
}
await ctx.db.patch(args.templateId, {
htmlCorpo: args.htmlCorpo,
});
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"templates",
JSON.stringify({ templateId: args.templateId, campo: "htmlCorpo" }),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Preview de template renderizado com variáveis de teste
*/
export const previewTemplate = query({
args: {
templateId: v.id("templatesMensagens"),
variaveisTeste: v.optional(v.record(v.string(), v.string())),
},
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return null;
}
// Variáveis padrão para teste
const variaveisPadrao: Record<string, string> = {
nome: "João Silva",
matricula: "12345",
senha: "Senha123!",
motivo: "Exemplo de motivo",
remetente: "Maria Santos",
mensagem: "Esta é uma mensagem de exemplo para preview do template.",
conversaId: "abc123",
urlSistema: getBaseUrl(),
solicitante: "João Silva",
numeroTicket: "TKT-2024-001",
prioridade: "Alta",
categoria: "Suporte Técnico",
responsavel: "Maria Santos",
descricao: "Exemplo de descrição de chamado",
destinario: "João Silva",
tipoPrazo: "resolução",
prazo: "24 horas",
status: "Em andamento",
rotaAcesso: "/ti/central-chamados",
titulo: "Título de Exemplo",
};
const variaveis = { ...variaveisPadrao, ...(args.variaveisTeste || {}) };
// Renderizar título e corpo
const tituloRenderizado = renderizarTemplate(template.titulo, variaveis);
const corpoRenderizado = renderizarTemplate(template.corpo, variaveis);
// Se tiver htmlCorpo, usar ele, senão gerar do corpo
let htmlFinal = template.htmlCorpo;
if (!htmlFinal) {
if (corpoRenderizado.includes("<") && corpoRenderizado.includes(">")) {
htmlFinal = wrapEmailHTML(corpoRenderizado, tituloRenderizado);
} else {
const corpoHTML = textToHTML(corpoRenderizado);
htmlFinal = wrapEmailHTML(corpoHTML, tituloRenderizado);
}
} else {
htmlFinal = renderizarTemplate(htmlFinal, variaveis);
}
return {
titulo: tituloRenderizado,
corpo: corpoRenderizado,
html: htmlFinal,
variaveisUsadas: template.variaveis || [],
};
},
});
/**
* Função auxiliar para obter URL base
*/
function getBaseUrl(): string {
const url = process.env.FRONTEND_URL || "http://localhost:5173";
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
return url;
}
/**
* Exportar templates (JSON)
*/
export const exportarTemplates = query({
args: {
templateIds: v.optional(v.array(v.id("templatesMensagens"))),
},
handler: async (ctx, args) => {
let templates;
if (args.templateIds && args.templateIds.length > 0) {
templates = await Promise.all(
args.templateIds.map((id) => ctx.db.get(id))
);
templates = templates.filter((t): t is Doc<"templatesMensagens"> => t !== null);
} else {
templates = await ctx.db.query("templatesMensagens").collect();
}
// Remover campos internos e retornar apenas dados exportáveis
return templates.map((t) => ({
codigo: t.codigo,
nome: t.nome,
tipo: t.tipo,
titulo: t.titulo,
corpo: t.corpo,
htmlCorpo: t.htmlCorpo,
variaveis: t.variaveis,
categoria: t.categoria,
tags: t.tags,
}));
},
});
/**
* Importar templates (JSON)
*/
export const importarTemplates = mutation({
args: {
templates: v.array(
v.object({
codigo: v.string(),
nome: v.string(),
tipo: v.optional(v.union(v.literal("sistema"), v.literal("customizado"))),
titulo: v.string(),
corpo: v.string(),
htmlCorpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
categoria: v.optional(v.union(v.literal("email"), v.literal("chat"), v.literal("ambos"))),
tags: v.optional(v.array(v.string())),
})
),
importadoPorId: v.id("usuarios"),
sobrescrever: v.optional(v.boolean()),
},
returns: v.object({
sucesso: v.boolean(),
importados: v.number(),
atualizados: v.number(),
erros: v.array(v.string()),
}),
handler: async (ctx, args) => {
let importados = 0;
let atualizados = 0;
const erros: string[] = [];
for (const templateData of args.templates) {
try {
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", templateData.codigo))
.first();
if (existente) {
if (args.sobrescrever && existente.tipo === "customizado") {
// Atualizar template existente
await ctx.db.patch(existente._id, {
nome: templateData.nome,
titulo: templateData.titulo,
corpo: templateData.corpo,
htmlCorpo: templateData.htmlCorpo,
variaveis: templateData.variaveis,
categoria: templateData.categoria,
tags: templateData.tags,
});
atualizados++;
} else {
erros.push(`Template ${templateData.codigo} já existe e sobrescrever está desabilitado`);
}
} else {
// Criar novo template
const tipo = templateData.tipo || "customizado";
// Gerar HTML se não fornecido
let htmlCorpo = templateData.htmlCorpo;
if (!htmlCorpo) {
if (templateData.corpo.includes("<") && templateData.corpo.includes(">")) {
htmlCorpo = wrapEmailHTML(templateData.corpo, templateData.titulo);
} else {
const corpoHTML = textToHTML(templateData.corpo);
htmlCorpo = wrapEmailHTML(corpoHTML, templateData.titulo);
}
}
await ctx.db.insert("templatesMensagens", {
codigo: templateData.codigo,
nome: templateData.nome,
tipo,
titulo: templateData.titulo,
corpo: templateData.corpo,
htmlCorpo,
variaveis: templateData.variaveis,
categoria: templateData.categoria || "email",
tags: templateData.tags,
criadoPor: args.importadoPorId,
criadoEm: Date.now(),
});
importados++;
}
} catch (error) {
const erroMsg = error instanceof Error ? error.message : String(error);
erros.push(`Erro ao importar ${templateData.codigo}: ${erroMsg}`);
}
}
await registrarAtividade(
ctx,
args.importadoPorId,
"importar",
"templates",
JSON.stringify({ importados, atualizados, erros: erros.length }),
undefined
);
return {
sucesso: erros.length === 0,
importados,
atualizados,
erros,
};
},
});
/**
* Duplicar template
*/
export const duplicarTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
novoCodigo: v.string(),
novoNome: v.optional(v.string()),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Verificar se novo código já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.novoCodigo))
.first();
if (existente) {
return { sucesso: false as const, erro: "Código de template já existe" };
}
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.novoCodigo,
nome: args.novoNome || `${template.nome} (Cópia)`,
tipo: "customizado",
titulo: template.titulo,
corpo: template.corpo,
htmlCorpo: template.htmlCorpo,
variaveis: template.variaveis,
categoria: template.categoria,
tags: template.tags,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
await registrarAtividade(
ctx,
args.criadoPorId,
"duplicar",
"templates",
JSON.stringify({ templateId, codigo: args.novoCodigo, originalId: args.templateId }),
templateId
);
return { sucesso: true as const, templateId };
},
});

View File

@@ -0,0 +1,46 @@
/**
* Wrapper para padronizar mensagens de chat do SGSE
*/
/**
* Formata mensagem de chat com prefixo padronizado quando necessário
* @param conteudo - Conteúdo da mensagem
* @param tipo - Tipo da mensagem (opcional)
* @returns Mensagem formatada
*/
export function wrapChatMessage(conteudo: string, tipo?: string): string {
// Se já tiver formatação especial, retornar como está
if (conteudo.includes('[SGSE]') || conteudo.includes('[Sistema]')) {
return conteudo;
}
// Para mensagens do sistema, adicionar prefixo
if (tipo === 'sistema' || tipo === 'notificacao') {
return `[SGSE] ${conteudo}`;
}
return conteudo;
}
/**
* Formata mensagem de chat com informações estruturadas
* @param titulo - Título da notificação
* @param conteudo - Conteúdo da mensagem
* @param acao - Ação sugerida (opcional)
* @returns Mensagem formatada
*/
export function formatChatNotification(
titulo: string,
conteudo: string,
acao?: string
): string {
let mensagem = `🔔 ${titulo}\n\n${conteudo}`;
if (acao) {
mensagem += `\n\n💡 ${acao}`;
}
return mensagem;
}

View File

@@ -0,0 +1,185 @@
/**
* Wrapper HTML para templates de email do SGSE
* Aplica estilo governamental profissional com logo e assinatura padronizada
*/
/**
* Obtém a URL base do sistema para uso em links de email
*/
function getBaseUrl(): string {
// Em produção, usar variável de ambiente
const url = process.env.FRONTEND_URL || "http://localhost:5173";
// Garantir que tenha protocolo
if (!url.match(/^https?:\/\//i)) {
return `http://${url}`;
}
return url;
}
/**
* Gera o HTML do header com logo do Governo de PE
*/
function generateHeader(): string {
const baseUrl = getBaseUrl();
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #1a3a52; padding: 20px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="text-align: center; padding: 20px 0;">
<img src="${baseUrl}/logo_governo_PE.png" alt="Governo de Pernambuco" style="max-width: 200px; height: auto;" />
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
}
/**
* Gera o HTML do footer com assinatura SGSE
*/
function generateFooter(): string {
const baseUrl = getBaseUrl();
const currentYear = new Date().getFullYear();
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; border-top: 3px solid #1a3a52; margin-top: 30px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="padding: 30px 20px;">
<tr>
<td style="text-align: center; font-family: Arial, sans-serif; color: #333333; font-size: 14px; line-height: 1.6;">
<p style="margin: 0 0 10px 0; font-weight: bold; color: #1a3a52; font-size: 16px;">
SGSE - Sistema de Gerenciamento de Secretaria
</p>
<p style="margin: 0 0 10px 0; color: #666666;">
Secretaria de Esportes do Estado de Pernambuco
</p>
<p style="margin: 0 0 15px 0; color: #666666; font-size: 12px;">
Este é um email automático do sistema. Por favor, não responda diretamente a este email.
</p>
<hr style="border: none; border-top: 1px solid #dddddd; margin: 20px 0;" />
<p style="margin: 0; color: #999999; font-size: 11px;">
© ${currentYear} Secretaria de Esportes - Governo de Pernambuco. Todos os direitos reservados.
</p>
<p style="margin: 5px 0 0 0; color: #999999; font-size: 11px;">
<a href="${baseUrl}" style="color: #1a3a52; text-decoration: none;">Acessar Sistema</a> |
<a href="${baseUrl}/ti/notificacoes" style="color: #1a3a52; text-decoration: none;">Central de Notificações</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
`;
}
/**
* Envolve o conteúdo HTML do email com template profissional governamental
* @param conteudoHTML - Conteúdo HTML do corpo do email
* @param titulo - Título do email (usado no meta)
* @returns HTML completo do email pronto para envio
*/
export function wrapEmailHTML(conteudoHTML: string, titulo?: string): string {
// Se o conteúdo já estiver dentro de um wrapper completo, retornar como está
if (conteudoHTML.includes('<!DOCTYPE html>') || conteudoHTML.includes('<html')) {
return conteudoHTML;
}
// Garantir que o conteúdo tenha estrutura básica
let conteudoProcessado = conteudoHTML.trim();
// Se não tiver tags HTML básicas, envolver em parágrafo
if (!conteudoProcessado.match(/^<[a-z]/i)) {
conteudoProcessado = `<p style="margin: 0 0 15px 0;">${conteudoProcessado}</p>`;
}
const header = generateHeader();
const footer = generateFooter();
const emailTitle = titulo || "Notificação do SGSE";
return `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${emailTitle}</title>
<!--[if mso]>
<style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
<!-- Wrapper principal -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; padding: 20px 0;">
<tr>
<td align="center">
<!-- Container do conteúdo -->
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color: #ffffff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden;">
<!-- Header -->
${header}
<!-- Corpo do email -->
<tr>
<td style="padding: 30px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-family: Arial, sans-serif; color: #333333; font-size: 14px; line-height: 1.6;">
${conteudoProcessado}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td>
${footer}
</td>
</tr>
</table>
<!-- Espaçamento inferior -->
<table width="600" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 20px 0; text-align: center; font-family: Arial, sans-serif; color: #999999; font-size: 11px;">
<p style="margin: 0;">Se você não solicitou este email, pode ignorá-lo com segurança.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
/**
* Converte texto plano em HTML básico
* @param texto - Texto plano
* @returns HTML formatado
*/
export function textToHTML(texto: string): string {
return texto
.split('\n')
.map(linha => {
const linhaTrim = linha.trim();
if (!linhaTrim) return '<br />';
// Detectar links
const linkRegex = /(https?:\/\/[^\s]+)/g;
const linhaComLinks = linhaTrim.replace(linkRegex, '<a href="$1" style="color: #1a3a52; text-decoration: underline;">$1</a>');
return `<p style="margin: 0 0 15px 0;">${linhaComLinks}</p>`;
})
.join('');
}

View File

@@ -0,0 +1,189 @@
/**
* Scanner automático de envios de email e mensagens no código
* Identifica todos os locais onde emails são enviados para gerar templates
*/
import { Doc } from "../_generated/dataModel";
export interface EmailSendLocation {
arquivo: string;
funcao: string;
tipo: "enfileirarEmail" | "enviarEmailComTemplate" | "enviarMensagem" | "html_inline";
linha?: number;
contexto?: string;
assunto?: string;
corpo?: string;
templateCodigo?: string;
variaveis?: string[];
}
/**
* Lista de locais conhecidos onde emails são enviados
* Este é um mapeamento manual baseado na análise do código
*/
export const LOCAIS_ENVIO_EMAIL: EmailSendLocation[] = [
// Chamados
{
arquivo: "packages/backend/convex/chamados.ts",
funcao: "registrarNotificacoes",
tipo: "enfileirarEmail",
contexto: "Notificação ao solicitante quando chamado é criado/atualizado",
assunto: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
variaveis: ["numeroTicket", "titulo", "mensagem"],
},
{
arquivo: "packages/backend/convex/chamados.ts",
funcao: "registrarNotificacoes",
tipo: "enfileirarEmail",
contexto: "Notificação ao responsável quando chamado é atualizado",
assunto: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
variaveis: ["numeroTicket", "titulo", "mensagem"],
},
// Ausências
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "solicitar",
tipo: "enfileirarEmail",
contexto: "Notificação ao gestor quando funcionário solicita ausência",
assunto: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"],
},
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "aprovar",
tipo: "enfileirarEmail",
contexto: "Notificação ao funcionário quando ausência é aprovada",
assunto: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"],
},
{
arquivo: "packages/backend/convex/ausencias.ts",
funcao: "reprovar",
tipo: "enfileirarEmail",
contexto: "Notificação ao funcionário quando ausência é reprovada",
assunto: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"],
},
// Chat
{
arquivo: "packages/backend/convex/chat.ts",
funcao: "enviarMensagem",
tipo: "enviarEmailComTemplate",
contexto: "Email quando usuário recebe nova mensagem no chat (usuário offline)",
templateCodigo: "chat_mensagem",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
{
arquivo: "packages/backend/convex/chat.ts",
funcao: "enviarMensagem",
tipo: "enviarEmailComTemplate",
contexto: "Email quando usuário é mencionado no chat (usuário offline)",
templateCodigo: "chat_mencao",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
},
// Painel de Notificações
{
arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte",
funcao: "enviarNotificacao",
tipo: "enfileirarEmail",
contexto: "Envio manual de notificação via painel de TI",
assunto: "Notificação do Sistema",
corpo: "{{mensagemPersonalizada}}",
variaveis: ["mensagemPersonalizada"],
},
{
arquivo: "apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte",
funcao: "enviarNotificacao",
tipo: "enviarEmailComTemplate",
contexto: "Envio manual de notificação usando template via painel de TI",
templateCodigo: "{{templateCodigo}}",
variaveis: ["nome", "matricula"],
},
];
/**
* Sugestões de templates baseadas nos locais de envio encontrados
*/
export interface TemplateSuggestion {
codigo: string;
nome: string;
titulo: string;
corpo: string;
categoria: "email" | "chat" | "ambos";
variaveis: string[];
tags: string[];
origem: string;
}
/**
* Gerar sugestões de templates baseadas nos locais de envio
*/
export function gerarSugestoesTemplates(): TemplateSuggestion[] {
const sugestoes: TemplateSuggestion[] = [];
// Template para ausência solicitada
sugestoes.push({
codigo: "ausencia_solicitada",
nome: "Ausência Solicitada",
titulo: "Nova Solicitação de Ausência - {{funcionarioNome}}",
corpo: "Olá {{gestorNome}},\n\nO funcionário <strong>{{funcionarioNome}}</strong> solicitou uma ausência:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>\n\nPor favor, acesse o sistema para aprovar ou reprovar esta solicitação.",
categoria: "email",
variaveis: ["gestorNome", "funcionarioNome", "dataInicio", "dataFim", "motivo"],
tags: ["ausencia", "solicitacao", "gestao"],
origem: "ausencias.ts - solicitar",
});
// Template para ausência aprovada
sugestoes.push({
codigo: "ausencia_aprovada",
nome: "Ausência Aprovada",
titulo: "Solicitação de Ausência Aprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li></ul>",
categoria: "email",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo"],
tags: ["ausencia", "aprovacao", "gestao"],
origem: "ausencias.ts - aprovar",
});
// Template para ausência reprovada
sugestoes.push({
codigo: "ausencia_reprovada",
nome: "Ausência Reprovada",
titulo: "Solicitação de Ausência Reprovada",
corpo: "Olá {{funcionarioNome}},\n\nSua solicitação de ausência foi <strong>reprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Motivo:</strong> {{motivo}}</li><li><strong>Motivo da Reprovação:</strong> {{motivoReprovacao}}</li></ul>",
categoria: "email",
variaveis: ["funcionarioNome", "gestorNome", "dataInicio", "dataFim", "motivo", "motivoReprovacao"],
tags: ["ausencia", "reprovacao", "gestao"],
origem: "ausencias.ts - reprovar",
});
// Template genérico para notificações de chamados
sugestoes.push({
codigo: "chamado_notificacao",
nome: "Notificação de Chamado",
titulo: "Chamado {{numeroTicket}} - {{titulo}}",
corpo: "{{mensagem}}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria",
categoria: "email",
variaveis: ["numeroTicket", "titulo", "mensagem"],
tags: ["chamado", "notificacao", "suporte"],
origem: "chamados.ts - registrarNotificacoes",
});
return sugestoes;
}
/**
* Obter todos os locais de envio de email
*/
export function obterLocaisEnvio(): EmailSendLocation[] {
return LOCAIS_ENVIO_EMAIL;
}