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:
@@ -134,6 +134,9 @@
|
|||||||
let processando = $state(false);
|
let processando = $state(false);
|
||||||
let criandoTemplates = $state(false);
|
let criandoTemplates = $state(false);
|
||||||
let progressoEnvio = $state({ total: 0, enviados: 0, falhas: 0 });
|
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
|
// Estrutura de dados para logs de envio
|
||||||
type StatusLog = 'sucesso' | 'erro' | 'fila' | 'info' | 'enviando';
|
type StatusLog = 'sucesso' | 'erro' | 'fila' | 'info' | 'enviando';
|
||||||
@@ -1173,6 +1176,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<!-- Formulário -->
|
<!-- Formulário -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
@@ -1645,7 +1678,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Histórico de Agendamentos -->
|
||||||
<div class="card bg-base-100 mt-6 shadow-xl">
|
<div class="card bg-base-100 mt-6 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -1864,6 +1918,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="alert alert-warning mt-6">
|
<div class="alert alert-warning mt-6">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
6
packages/backend/convex/_generated/api.d.ts
vendored
6
packages/backend/convex/_generated/api.d.ts
vendored
@@ -57,7 +57,10 @@ import type * as templatesMensagens from "../templatesMensagens.js";
|
|||||||
import type * as times from "../times.js";
|
import type * as times from "../times.js";
|
||||||
import type * as todos from "../todos.js";
|
import type * as todos from "../todos.js";
|
||||||
import type * as usuarios from "../usuarios.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_getClientIP from "../utils/getClientIP.js";
|
||||||
|
import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js";
|
||||||
import type * as verificarMatriculas from "../verificarMatriculas.js";
|
import type * as verificarMatriculas from "../verificarMatriculas.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -116,7 +119,10 @@ declare const fullApi: ApiFromModules<{
|
|||||||
times: typeof times;
|
times: typeof times;
|
||||||
todos: typeof todos;
|
todos: typeof todos;
|
||||||
usuarios: typeof usuarios;
|
usuarios: typeof usuarios;
|
||||||
|
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
|
||||||
|
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
|
||||||
"utils/getClientIP": typeof utils_getClientIP;
|
"utils/getClientIP": typeof utils_getClientIP;
|
||||||
|
"utils/scanEmailSenders": typeof utils_scanEmailSenders;
|
||||||
verificarMatriculas: typeof verificarMatriculas;
|
verificarMatriculas: typeof verificarMatriculas;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -358,20 +358,45 @@ export const criarSolicitacao = mutation({
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (gestorUsuario && funcionarioUsuario) {
|
if (gestorUsuario && funcionarioUsuario) {
|
||||||
// Enviar email ao gestor
|
// Obter URL do sistema
|
||||||
await ctx.runMutation(api.email.enfileirarEmail, {
|
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
destinatario: gestorUsuario.email,
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
destinatarioId: gestorId,
|
urlSistema = `http://${urlSistema}`;
|
||||||
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>
|
// Enviar email ao gestor usando template
|
||||||
<ul>
|
try {
|
||||||
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}</li>
|
await ctx.runAction(api.email.enviarEmailComTemplate, {
|
||||||
<li><strong>Motivo:</strong> ${args.motivo}</li>
|
destinatario: gestorUsuario.email,
|
||||||
</ul>
|
destinatarioId: gestorId,
|
||||||
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
|
templateCodigo: "ausencia_solicitada",
|
||||||
enviadoPor: funcionarioUsuario._id,
|
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
|
// Criar ou obter conversa entre gestor e funcionário
|
||||||
const conversasExistentes = await ctx.db
|
const conversasExistentes = await ctx.db
|
||||||
@@ -475,19 +500,44 @@ export const aprovar = mutation({
|
|||||||
const gestorUsuario = await ctx.db.get(args.gestorId);
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||||
|
|
||||||
if (gestorUsuario) {
|
if (gestorUsuario) {
|
||||||
// Enviar email ao funcionário
|
// Obter URL do sistema
|
||||||
await ctx.runMutation(api.email.enfileirarEmail, {
|
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
destinatario: funcionarioUsuario.email,
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
destinatarioId: funcionarioUsuario._id,
|
urlSistema = `http://${urlSistema}`;
|
||||||
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>
|
// Enviar email ao funcionário usando template
|
||||||
<ul>
|
try {
|
||||||
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
|
await ctx.runAction(api.email.enviarEmailComTemplate, {
|
||||||
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
destinatario: funcionarioUsuario.email,
|
||||||
</ul>`,
|
destinatarioId: funcionarioUsuario._id,
|
||||||
enviadoPor: args.gestorId,
|
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
|
// Criar ou obter conversa
|
||||||
const conversasExistentes = await ctx.db
|
const conversasExistentes = await ctx.db
|
||||||
@@ -593,20 +643,46 @@ export const reprovar = mutation({
|
|||||||
const gestorUsuario = await ctx.db.get(args.gestorId);
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||||
|
|
||||||
if (gestorUsuario) {
|
if (gestorUsuario) {
|
||||||
// Enviar email ao funcionário
|
// Obter URL do sistema
|
||||||
await ctx.runMutation(api.email.enfileirarEmail, {
|
let urlSistema = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
destinatario: funcionarioUsuario.email,
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
destinatarioId: funcionarioUsuario._id,
|
urlSistema = `http://${urlSistema}`;
|
||||||
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>
|
// Enviar email ao funcionário usando template
|
||||||
<ul>
|
try {
|
||||||
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
|
await ctx.runAction(api.email.enviarEmailComTemplate, {
|
||||||
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
destinatario: funcionarioUsuario.email,
|
||||||
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
|
destinatarioId: funcionarioUsuario._id,
|
||||||
</ul>`,
|
templateCodigo: "ausencia_reprovada",
|
||||||
enviadoPor: args.gestorId,
|
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
|
// Criar ou obter conversa
|
||||||
const conversasExistentes = await ctx.db
|
const conversasExistentes = await ctx.db
|
||||||
|
|||||||
@@ -121,15 +121,38 @@ async function registrarNotificacoes(
|
|||||||
) {
|
) {
|
||||||
const { ticket, titulo, mensagem, usuarioEvento } = params;
|
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
|
// Notificar solicitante
|
||||||
if (ticket.solicitanteEmail) {
|
if (ticket.solicitanteEmail) {
|
||||||
await ctx.runMutation(api.email.enfileirarEmail, {
|
// Tentar usar template, senão usar envio direto
|
||||||
destinatario: ticket.solicitanteEmail,
|
try {
|
||||||
destinatarioId: ticket.solicitanteId,
|
await ctx.runAction(api.email.enviarEmailComTemplate, {
|
||||||
assunto: `${titulo} - Chamado ${ticket.numero}`,
|
destinatario: ticket.solicitanteEmail,
|
||||||
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
|
destinatarioId: ticket.solicitanteId,
|
||||||
enviadoPor: usuarioEvento,
|
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", {
|
await ctx.db.insert("notificacoes", {
|
||||||
@@ -147,13 +170,30 @@ async function registrarNotificacoes(
|
|||||||
if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) {
|
if (ticket.responsavelId && ticket.responsavelId !== ticket.solicitanteId) {
|
||||||
const responsavel = await ctx.db.get(ticket.responsavelId);
|
const responsavel = await ctx.db.get(ticket.responsavelId);
|
||||||
if (responsavel?.email) {
|
if (responsavel?.email) {
|
||||||
await ctx.runMutation(api.email.enfileirarEmail, {
|
// Tentar usar template, senão usar envio direto
|
||||||
destinatario: responsavel.email,
|
try {
|
||||||
destinatarioId: ticket.responsavelId,
|
await ctx.runAction(api.email.enviarEmailComTemplate, {
|
||||||
assunto: `${titulo} - Chamado ${ticket.numero}`,
|
destinatario: responsavel.email,
|
||||||
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE - Sistema de Gerenciamento de Secretaria`,
|
destinatarioId: ticket.responsavelId,
|
||||||
enviadoPor: usuarioEvento,
|
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", {
|
await ctx.db.insert("notificacoes", {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { v } from "convex/values";
|
|||||||
import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
|
import { mutation, query, internalMutation, internalQuery, action } from "./_generated/server";
|
||||||
import { internal, api } from "./_generated/api";
|
import { internal, api } from "./_generated/api";
|
||||||
import { renderizarTemplate } from "./templatesMensagens";
|
import { renderizarTemplate } from "./templatesMensagens";
|
||||||
|
import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper";
|
||||||
import type { Doc, Id } from "./_generated/dataModel";
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
// ========== INTERNAL QUERIES ==========
|
// ========== INTERNAL QUERIES ==========
|
||||||
@@ -221,12 +222,27 @@ export const enviarEmailComTemplate = action({
|
|||||||
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
|
const tituloRenderizado = renderizarTemplate(template.titulo, variaveisTemplate);
|
||||||
const corpoRenderizado = renderizarTemplate(template.corpo, 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
|
// Enfileirar email via mutation
|
||||||
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
|
const emailId: Id<"notificacoesEmail"> = await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
destinatario: args.destinatario,
|
destinatario: args.destinatario,
|
||||||
destinatarioId: args.destinatarioId,
|
destinatarioId: args.destinatarioId,
|
||||||
assunto: tituloRenderizado,
|
assunto: tituloRenderizado,
|
||||||
corpo: corpoRenderizado,
|
corpo: corpoHTML, // Usar HTML completo
|
||||||
templateId: template._id, // template._id sempre existe se template não é null
|
templateId: template._id, // template._id sempre existe se template não é null
|
||||||
enviadoPor: args.enviadoPor,
|
enviadoPor: args.enviadoPor,
|
||||||
agendadaPara: args.agendadaPara,
|
agendadaPara: args.agendadaPara,
|
||||||
|
|||||||
@@ -851,13 +851,19 @@ export default defineSchema({
|
|||||||
),
|
),
|
||||||
titulo: v.string(),
|
titulo: v.string(),
|
||||||
corpo: v.string(), // pode ter variáveis {{variavel}}
|
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.]
|
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")),
|
criadoPor: v.optional(v.id("usuarios")),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_codigo", ["codigo"])
|
.index("by_codigo", ["codigo"])
|
||||||
.index("by_tipo", ["tipo"])
|
.index("by_tipo", ["tipo"])
|
||||||
.index("by_criado_por", ["criadoPor"]),
|
.index("by_criado_por", ["criadoPor"])
|
||||||
|
.index("by_categoria", ["categoria"]),
|
||||||
|
|
||||||
// Configuração de Email/SMTP
|
// Configuração de Email/SMTP
|
||||||
configuracaoEmail: defineTable({
|
configuracaoEmail: defineTable({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { v } from "convex/values";
|
|||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
import { registrarAtividade } from "./logsAtividades";
|
import { registrarAtividade } from "./logsAtividades";
|
||||||
import { Doc } from "./_generated/dataModel";
|
import { Doc } from "./_generated/dataModel";
|
||||||
|
import { wrapEmailHTML, textToHTML } from "./utils/emailTemplateWrapper";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listar todos os templates
|
* Listar todos os templates
|
||||||
@@ -40,7 +41,10 @@ export const criarTemplate = mutation({
|
|||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
titulo: v.string(),
|
titulo: v.string(),
|
||||||
corpo: v.string(),
|
corpo: v.string(),
|
||||||
|
htmlCorpo: v.optional(v.string()),
|
||||||
variaveis: v.optional(v.array(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"),
|
criadoPorId: v.id("usuarios"),
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
@@ -58,6 +62,18 @@ export const criarTemplate = mutation({
|
|||||||
return { sucesso: false as const, erro: "Código de template já existe" };
|
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
|
// Criar template
|
||||||
const templateId = await ctx.db.insert("templatesMensagens", {
|
const templateId = await ctx.db.insert("templatesMensagens", {
|
||||||
codigo: args.codigo,
|
codigo: args.codigo,
|
||||||
@@ -65,7 +81,10 @@ export const criarTemplate = mutation({
|
|||||||
tipo: "customizado",
|
tipo: "customizado",
|
||||||
titulo: args.titulo,
|
titulo: args.titulo,
|
||||||
corpo: args.corpo,
|
corpo: args.corpo,
|
||||||
|
htmlCorpo,
|
||||||
variaveis: args.variaveis,
|
variaveis: args.variaveis,
|
||||||
|
categoria: args.categoria || "email",
|
||||||
|
tags: args.tags,
|
||||||
criadoPor: args.criadoPorId,
|
criadoPor: args.criadoPorId,
|
||||||
criadoEm: Date.now(),
|
criadoEm: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -93,7 +112,10 @@ export const editarTemplate = mutation({
|
|||||||
nome: v.optional(v.string()),
|
nome: v.optional(v.string()),
|
||||||
titulo: v.optional(v.string()),
|
titulo: v.optional(v.string()),
|
||||||
corpo: v.optional(v.string()),
|
corpo: v.optional(v.string()),
|
||||||
|
htmlCorpo: v.optional(v.string()),
|
||||||
variaveis: v.optional(v.array(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"),
|
editadoPorId: v.id("usuarios"),
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
@@ -116,7 +138,21 @@ export const editarTemplate = mutation({
|
|||||||
if (args.nome !== undefined) updates.nome = args.nome;
|
if (args.nome !== undefined) updates.nome = args.nome;
|
||||||
if (args.titulo !== undefined) updates.titulo = args.titulo;
|
if (args.titulo !== undefined) updates.titulo = args.titulo;
|
||||||
if (args.corpo !== undefined) updates.corpo = args.corpo;
|
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.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);
|
await ctx.db.patch(args.templateId, updates);
|
||||||
|
|
||||||
@@ -396,6 +432,33 @@ export const criarTemplatesPadrao = mutation({
|
|||||||
+ "</div></body></html>",
|
+ "</div></body></html>",
|
||||||
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
|
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) {
|
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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
46
packages/backend/convex/utils/chatTemplateWrapper.ts
Normal file
46
packages/backend/convex/utils/chatTemplateWrapper.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
185
packages/backend/convex/utils/emailTemplateWrapper.ts
Normal file
185
packages/backend/convex/utils/emailTemplateWrapper.ts
Normal 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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
189
packages/backend/convex/utils/scanEmailSenders.ts
Normal file
189
packages/backend/convex/utils/scanEmailSenders.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user