Feat cibersecurity #27

Merged
deyvisonwanderley merged 14 commits from feat-cibersecurity into master 2025-11-17 14:49:34 +00:00
42 changed files with 6330 additions and 3896 deletions
Showing only changes of commit fb784d6f7e - Show all commits

View File

@@ -1,28 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel"; import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
type SlaConfig = Doc<"slaConfigs">;
interface FormValues { interface FormValues {
titulo: string; titulo: string;
descricao: string; descricao: string;
tipo: Doc<"tickets">["tipo"]; tipo: Doc<"tickets">["tipo"];
prioridade: Doc<"tickets">["prioridade"]; prioridade: Doc<"tickets">["prioridade"];
categoria: string; categoria: string;
slaConfigId?: Id<"slaConfigs">;
canalOrigem?: string; canalOrigem?: string;
anexos: File[]; anexos: File[];
} }
interface Props { interface Props {
slaConfigs?: Array<SlaConfig>;
loading?: boolean; loading?: boolean;
} }
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>(); const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
const props = $props<Props>(); const props = $props<Props>();
const slaConfigs = $derived<Array<SlaConfig>>(props.slaConfigs ?? []);
const loading = $derived(props.loading ?? false); const loading = $derived(props.loading ?? false);
let titulo = $state(""); let titulo = $state("");
@@ -30,7 +25,6 @@ const loading = $derived(props.loading ?? false);
let tipo = $state<Doc<"tickets">["tipo"]>("chamado"); let tipo = $state<Doc<"tickets">["tipo"]>("chamado");
let prioridade = $state<Doc<"tickets">["prioridade"]>("media"); let prioridade = $state<Doc<"tickets">["prioridade"]>("media");
let categoria = $state(""); let categoria = $state("");
let slaConfigId = $state<Id<"slaConfigs"> | "">("");
let canalOrigem = $state("Portal SGSE"); let canalOrigem = $state("Portal SGSE");
let anexos = $state<Array<File>>([]); let anexos = $state<Array<File>>([]);
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
@@ -67,10 +61,6 @@ let slaConfigId = $state<Id<"slaConfigs"> | "">("");
event.preventDefault(); event.preventDefault();
if (!validate()) return; if (!validate()) return;
const slaSelecionada =
(slaConfigId && slaConfigId !== "" ? (slaConfigId as Id<"slaConfigs">) : slaConfigs[0]?._id) ??
undefined;
dispatch("submit", { dispatch("submit", {
values: { values: {
titulo: titulo.trim(), titulo: titulo.trim(),
@@ -78,7 +68,6 @@ let slaConfigId = $state<Id<"slaConfigs"> | "">("");
tipo, tipo,
prioridade, prioridade,
categoria: categoria.trim(), categoria: categoria.trim(),
slaConfigId: slaSelecionada,
canalOrigem, canalOrigem,
anexos, anexos,
}, },
@@ -150,22 +139,6 @@ let slaConfigId = $state<Id<"slaConfigs"> | "">("");
<span class="text-error mt-1 text-sm">{errors.categoria}</span> <span class="text-error mt-1 text-sm">{errors.categoria}</span>
{/if} {/if}
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Configuração de SLA</span>
</label>
<select class="select select-bordered w-full" bind:value={slaConfigId}>
{#each slaConfigs as sla (sla._id)}
<option value={sla._id}>
{sla.nome} • Resp. {sla.tempoRespostaHoras}h • Conc.
{sla.tempoConclusaoHoras}h
</option>
{:else}
<option value="">Padrão (24h)</option>
{/each}
</select>
</div>
</section> </section>
<section class="form-control"> <section class="form-control">

View File

@@ -10,12 +10,9 @@
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth"; import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
type Ticket = Doc<"tickets">; type Ticket = Doc<"tickets">;
type SlaConfig = Doc<"slaConfigs">;
const client = useConvexClient(); const client = useConvexClient();
let slaConfigs = $state<Array<SlaConfig>>([]);
let carregandoSla = $state(true);
let submitLoading = $state(false); let submitLoading = $state(false);
let resetSignal = $state(0); let resetSignal = $state(0);
let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>( let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>(
@@ -42,28 +39,11 @@
}, },
]); ]);
onMount(() => {
carregarSlaConfigs();
});
$effect(() => { $effect(() => {
// Garante que o cliente Convex use o token do usuário logado // Garante que o cliente Convex use o token do usuário logado
useConvexWithAuth(); useConvexWithAuth();
}); });
async function carregarSlaConfigs() {
try {
carregandoSla = true;
const lista = await client.query(api.chamados.listarSlaConfigs, {});
slaConfigs = lista ?? [];
} catch (error) {
console.error("Erro ao carregar SLA:", error);
slaConfigs = [];
} finally {
carregandoSla = false;
}
}
async function uploadArquivo(file: File) { async function uploadArquivo(file: File) {
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {}); const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
@@ -104,7 +84,6 @@
tipo: values.tipo, tipo: values.tipo,
categoria: values.categoria, categoria: values.categoria,
prioridade: values.prioridade, prioridade: values.prioridade,
slaConfigId: values.slaConfigId,
canalOrigem: values.canalOrigem, canalOrigem: values.canalOrigem,
anexos, anexos,
}); });
@@ -192,53 +171,15 @@
</p> </p>
<div class="mt-6"> <div class="mt-6">
{#if resetSignal % 2 === 0} {#if resetSignal % 2 === 0}
<TicketForm {slaConfigs} loading={submitLoading} on:submit={handleSubmit} /> <TicketForm loading={submitLoading} on:submit={handleSubmit} />
{:else} {:else}
<TicketForm {slaConfigs} loading={submitLoading} on:submit={handleSubmit} /> <TicketForm loading={submitLoading} on:submit={handleSubmit} />
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
<aside class="space-y-6"> <aside class="space-y-6">
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
<h3 class="font-semibold text-base-content">Configurações de SLA</h3>
{#if carregandoSla}
<div class="flex items-center justify-center py-6">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if slaConfigs.length === 0}
<p class="text-sm text-base-content/60">
Nenhuma configuração customizada cadastrada. Os prazos padrão serão aplicados (Resposta:
4h, Conclusão: 24h).
</p>
{:else}
<div class="mt-4 space-y-4">
{#each slaConfigs as sla (sla._id)}
<div class="rounded-2xl border border-primary/20 bg-primary/5 p-4">
<p class="text-sm font-semibold text-primary">{sla.nome}</p>
<p class="text-xs text-base-content/60">{sla.descricao}</p>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
<div class="rounded-xl bg-base-100/90 p-2 text-center">
<p class="font-semibold">{sla.tempoRespostaHoras}h</p>
<p class="text-base-content/60">Resposta</p>
</div>
<div class="rounded-xl bg-base-100/90 p-2 text-center">
<p class="font-semibold">{sla.tempoConclusaoHoras}h</p>
<p class="text-base-content/60">Conclusão</p>
</div>
</div>
{#if sla.alertaAntecedenciaHoras}
<p class="text-[11px] text-base-content/50 mt-2">
Alerta {sla.alertaAntecedenciaHoras}h antes do prazo.
</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg"> <div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
<h3 class="font-semibold text-base-content">Como funciona a timeline</h3> <h3 class="font-semibold text-base-content">Como funciona a timeline</h3>
<p class="text-sm text-base-content/60 mb-4"> <p class="text-sm text-base-content/60 mb-4">

View File

@@ -15,10 +15,51 @@
type Ticket = Doc<"tickets">; type Ticket = Doc<"tickets">;
type Usuario = Doc<"usuarios">; type Usuario = Doc<"usuarios">;
type SlaConfig = Doc<"slaConfigs">; type SlaConfig = Doc<"slaConfigs">;
type Template = Doc<"templatesMensagens">;
const client = useConvexClient(); const client = useConvexClient();
const usuariosQuery = useQuery(api.usuarios.listar, {}); const usuariosQuery = useQuery(api.usuarios.listar, {});
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {}); const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
// Extrair dados dos templates
const templates = $derived.by(() => {
if (!templatesQuery || templatesQuery === undefined || templatesQuery === null) {
return [];
}
// Se tem propriedade data, usar os dados
if ('data' in templatesQuery && templatesQuery.data !== undefined) {
return Array.isArray(templatesQuery.data) ? templatesQuery.data : [];
}
// Se templatesQuery é diretamente um array (caso não tenha .data)
if (Array.isArray(templatesQuery)) {
return templatesQuery;
}
return [];
});
const carregandoTemplates = $derived.by(() => {
if (!templatesQuery || templatesQuery === undefined || templatesQuery === null) {
return true;
}
if (typeof templatesQuery === 'object' && Object.keys(templatesQuery).length === 0) {
return true;
}
if (!('data' in templatesQuery)) {
return true;
}
if (templatesQuery.data === undefined) {
return true;
}
return false;
});
const templatesChamados = $derived(() => {
return templates.filter((t: Template) => {
if (!t.codigo) return false;
return typeof t.codigo === 'string' && t.codigo.startsWith("chamado_");
});
});
let carregandoChamados = $state(true); let carregandoChamados = $state(true);
let tickets = $state<Array<Ticket>>([]); let tickets = $state<Array<Ticket>>([]);
@@ -34,6 +75,7 @@
slaId?: Id<"slaConfigs">; slaId?: Id<"slaConfigs">;
nome: string; nome: string;
descricao: string; descricao: string;
prioridade: "baixa" | "media" | "alta" | "critica";
tempoRespostaHoras: number; tempoRespostaHoras: number;
tempoConclusaoHoras: number; tempoConclusaoHoras: number;
tempoEncerramentoHoras?: number | null; tempoEncerramentoHoras?: number | null;
@@ -42,6 +84,7 @@
}>({ }>({
nome: "", nome: "",
descricao: "", descricao: "",
prioridade: "media",
tempoRespostaHoras: 4, tempoRespostaHoras: 4,
tempoConclusaoHoras: 24, tempoConclusaoHoras: 24,
tempoEncerramentoHoras: 72, tempoEncerramentoHoras: 72,
@@ -49,6 +92,21 @@
ativo: true, ativo: true,
}); });
let slaFeedback = $state<string | null>(null); let slaFeedback = $state<string | null>(null);
let slaParaExcluir = $state<Id<"slaConfigs"> | null>(null);
let prorrogacaoForm = $state<{
ticketId: Id<"tickets"> | "";
horasAdicionais: number;
prazo: "resposta" | "conclusao";
motivo: string;
}>({
ticketId: "",
horasAdicionais: 24,
prazo: "conclusao",
motivo: "",
});
let prorrogacaoFeedback = $state<string | null>(null);
let criandoTemplates = $state(false);
let templatesFeedback = $state<string | null>(null);
let carregamentoToken = 0; let carregamentoToken = 0;
$effect(() => { $effect(() => {
@@ -63,9 +121,6 @@
onMount(() => { onMount(() => {
// Configura token no cliente Convex // Configura token no cliente Convex
useConvexWithAuth(); useConvexWithAuth();
if (slaConfigsQuery?.data && slaConfigsQuery.data.length > 0) {
selecionarSla(slaConfigsQuery.data[0]);
}
}); });
async function carregarChamados(filtros: { async function carregarChamados(filtros: {
@@ -98,9 +153,10 @@
detalheSelecionado = tickets.find((t) => t._id === ticketId) ?? null; detalheSelecionado = tickets.find((t) => t._id === ticketId) ?? null;
} }
const usuariosTI = $derived( const usuariosTI = $derived(() => {
(usuariosQuery?.data || []).filter((usuario: Usuario) => usuario.setor === "TI") if (!usuariosQuery?.data) return [];
); return usuariosQuery.data.filter((usuario: Usuario) => usuario.setor === "TI");
});
const estatisticas = $derived(() => { const estatisticas = $derived(() => {
const total = tickets.length; const total = tickets.length;
@@ -117,6 +173,7 @@
slaId: sla._id, slaId: sla._id,
nome: sla.nome, nome: sla.nome,
descricao: sla.descricao ?? "", descricao: sla.descricao ?? "",
prioridade: sla.prioridade,
tempoRespostaHoras: sla.tempoRespostaHoras, tempoRespostaHoras: sla.tempoRespostaHoras,
tempoConclusaoHoras: sla.tempoConclusaoHoras, tempoConclusaoHoras: sla.tempoConclusaoHoras,
tempoEncerramentoHoras: sla.tempoEncerramentoHoras ?? undefined, tempoEncerramentoHoras: sla.tempoEncerramentoHoras ?? undefined,
@@ -125,23 +182,86 @@
}; };
} }
function novoSla() {
slaForm = {
nome: "",
descricao: "",
prioridade: "media",
tempoRespostaHoras: 4,
tempoConclusaoHoras: 24,
tempoEncerramentoHoras: 72,
alertaAntecedenciaHoras: 2,
ativo: true,
};
slaFeedback = null;
}
async function salvarSlaConfig() { async function salvarSlaConfig() {
try { try {
slaFeedback = null; slaFeedback = null;
if (!slaForm.nome.trim()) {
slaFeedback = "Nome é obrigatório";
return;
}
await client.mutation(api.chamados.salvarSlaConfig, { await client.mutation(api.chamados.salvarSlaConfig, {
...slaForm, slaId: slaForm.slaId,
tempoEncerramentoHoras: slaForm.tempoEncerramentoHoras, nome: slaForm.nome,
descricao: slaForm.descricao || undefined,
prioridade: slaForm.prioridade,
tempoRespostaHoras: slaForm.tempoRespostaHoras,
tempoConclusaoHoras: slaForm.tempoConclusaoHoras,
tempoEncerramentoHoras: slaForm.tempoEncerramentoHoras || undefined,
alertaAntecedenciaHoras: slaForm.alertaAntecedenciaHoras,
ativo: slaForm.ativo,
}); });
slaFeedback = "Configuração salva com sucesso."; slaFeedback = "Configuração salva com sucesso";
// Recarregar SLAs após salvar
if (slaConfigsQuery?.data) {
const slaAtualizado = await client.query(api.chamados.listarSlaConfigs, {});
if (slaAtualizado && slaAtualizado.length > 0) {
const slaEncontrado = slaAtualizado.find((s: SlaConfig) =>
s.prioridade === slaForm.prioridade && s.ativo === slaForm.ativo
);
if (slaEncontrado) {
selecionarSla(slaEncontrado);
}
}
}
} catch (error) { } catch (error) {
slaFeedback = slaFeedback =
error instanceof Error ? error.message : "Erro ao salvar configuração de SLA."; error instanceof Error ? error.message : "Erro ao salvar configuração de SLA";
} }
} }
async function excluirSlaConfig() {
if (!slaParaExcluir) return;
try {
slaFeedback = null;
await client.mutation(api.chamados.excluirSlaConfig, {
slaId: slaParaExcluir,
});
slaFeedback = "Configuração excluída com sucesso";
slaParaExcluir = null;
novoSla();
} catch (error) {
slaFeedback =
error instanceof Error ? error.message : "Erro ao excluir configuração de SLA";
}
}
const slaConfigsPorPrioridade = $derived(() => {
const slaConfigs = slaConfigsQuery?.data || [];
return {
baixa: slaConfigs.find((s: SlaConfig) => s.prioridade === "baixa" && s.ativo),
media: slaConfigs.find((s: SlaConfig) => s.prioridade === "media" && s.ativo),
alta: slaConfigs.find((s: SlaConfig) => s.prioridade === "alta" && s.ativo),
critica: slaConfigs.find((s: SlaConfig) => s.prioridade === "critica" && s.ativo),
};
});
async function atribuirResponsavel() { async function atribuirResponsavel() {
if (!ticketSelecionado || !assignResponsavel) { if (!ticketSelecionado || !assignResponsavel) {
assignFeedback = "Escolha um ticket e um responsável."; assignFeedback = "Escolha um ticket e um responsável";
return; return;
} }
try { try {
@@ -151,8 +271,9 @@
responsavelId: assignResponsavel as Id<"usuarios">, responsavelId: assignResponsavel as Id<"usuarios">,
motivo: assignMotivo || undefined, motivo: assignMotivo || undefined,
}); });
assignFeedback = "Responsável atribuído."; assignFeedback = "Responsável atribuído com sucesso";
assignMotivo = ""; assignMotivo = "";
assignResponsavel = "";
await carregarChamados({ await carregarChamados({
status: filtroStatus === "todos" ? undefined : filtroStatus, status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel, responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
@@ -162,9 +283,74 @@
selecionarTicket(ticketSelecionado); selecionarTicket(ticketSelecionado);
} }
} catch (error) { } catch (error) {
assignFeedback = error instanceof Error ? error.message : "Erro ao atribuir responsável."; assignFeedback = error instanceof Error ? error.message : "Erro ao atribuir responsável";
} }
} }
async function prorrogarChamado() {
if (!prorrogacaoForm.ticketId || !prorrogacaoForm.motivo.trim()) {
prorrogacaoFeedback = "Selecione um ticket e preencha o motivo da prorrogação";
return;
}
if (prorrogacaoForm.horasAdicionais <= 0) {
prorrogacaoFeedback = "O número de horas deve ser maior que zero";
return;
}
try {
prorrogacaoFeedback = null;
await client.mutation(api.chamados.prorrogarChamado, {
ticketId: prorrogacaoForm.ticketId as Id<"tickets">,
horasAdicionais: prorrogacaoForm.horasAdicionais,
prazo: prorrogacaoForm.prazo,
motivo: prorrogacaoForm.motivo.trim(),
});
const ticketIdProrrogado = prorrogacaoForm.ticketId;
prorrogacaoFeedback = "Prazo prorrogado com sucesso";
prorrogacaoForm = {
ticketId: "",
horasAdicionais: 24,
prazo: "conclusao",
motivo: "",
};
await carregarChamados({
status: filtroStatus === "todos" ? undefined : filtroStatus,
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
setor: filtroSetor === "todos" ? undefined : filtroSetor,
});
if (ticketIdProrrogado) {
selecionarTicket(ticketIdProrrogado as Id<"tickets">);
}
} catch (error) {
prorrogacaoFeedback = error instanceof Error ? error.message : "Erro ao prorrogar prazo";
}
}
async function criarTemplatesPadrao() {
try {
criandoTemplates = true;
templatesFeedback = null;
const resultado = await client.mutation(api.templatesMensagens.criarTemplatesPadrao, {});
templatesFeedback = resultado?.sucesso ? "Templates padrão criados com sucesso" : "Templates criados";
// Aguardar um pouco para os templates aparecerem e forçar reload da query
await new Promise((resolve) => setTimeout(resolve, 1000));
// Forçar reload dos templates
if (templatesQuery) {
// A query será atualizada automaticamente pelo useQuery
}
} catch (error) {
console.error("Erro ao criar templates:", error);
templatesFeedback = error instanceof Error ? error.message : "Erro ao criar templates padrão";
} finally {
criandoTemplates = false;
}
}
// Debug: ver templates carregados
$effect(() => {
console.log("templatesQuery:", templatesQuery);
console.log("Templates extraídos:", templates);
console.log("Templates de chamados:", templatesChamados);
});
</script> </script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8"> <main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
@@ -313,21 +499,94 @@
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<select class="select select-bordered w-full" bind:value={assignResponsavel}> <select class="select select-bordered w-full" bind:value={assignResponsavel}>
<option value="">Selecione o responsável</option> <option value="">Selecione o responsável</option>
{#if usuariosTI.length === 0}
<option disabled>Carregando usuários...</option>
{:else}
{#each usuariosTI as usuario (usuario._id)} {#each usuariosTI as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option> <option value={usuario._id}>{usuario.nome}</option>
{/each} {/each}
{/if}
</select> </select>
<textarea <textarea
class="textarea textarea-bordered w-full" class="textarea textarea-bordered w-full"
rows="3" rows="3"
placeholder="Motivo/observação" placeholder="Motivo/observação (opcional)"
bind:value={assignMotivo} bind:value={assignMotivo}
></textarea> ></textarea>
{#if assignFeedback} {#if assignFeedback}
<p class="text-sm text-base-content/70">{assignFeedback}</p> <div class={`alert ${assignFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} text-sm`}>
{assignFeedback}
</div>
{/if} {/if}
<button class="btn btn-primary w-full" type="button" onclick={atribuirResponsavel}> <button class="btn btn-primary w-full" type="button" onclick={atribuirResponsavel}>
Salvar Atribuir responsável
</button>
</div>
</div>
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
<h3 class="text-lg font-semibold text-base-content">Prorrogar prazo</h3>
<p class="text-xs text-base-content/60 mt-1">Recurso exclusivo para a equipe de TI</p>
<div class="mt-4 space-y-3">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Ticket *</span>
</label>
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.ticketId}>
<option value="">Selecione o ticket</option>
{#if tickets.length === 0}
<option disabled>Carregando tickets...</option>
{:else}
{#each tickets as ticket (ticket._id)}
<option value={ticket._id}>{ticket.numero} - {ticket.titulo}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Prazo a prorrogar *</span>
</label>
<select class="select select-bordered w-full" bind:value={prorrogacaoForm.prazo}>
<option value="resposta">Prazo de resposta</option>
<option value="conclusao">Prazo de conclusão</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Horas adicionais</span>
</label>
<input
type="number"
min="1"
class="input input-bordered w-full"
bind:value={prorrogacaoForm.horasAdicionais}
placeholder="Ex: 24"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Motivo *</span>
</label>
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="Descreva o motivo da prorrogação..."
bind:value={prorrogacaoForm.motivo}
></textarea>
</div>
{#if prorrogacaoFeedback}
<div class={`alert ${prorrogacaoFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} text-sm`}>
{prorrogacaoFeedback}
</div>
{/if}
<button
class="btn btn-warning w-full"
type="button"
onclick={prorrogarChamado}
disabled={!prorrogacaoForm.ticketId}
>
Prorrogar prazo
</button> </button>
</div> </div>
</div> </div>
@@ -336,56 +595,242 @@
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl"> <section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<h3 class="text-lg font-semibold text-base-content">Configuração de SLA</h3> <h3 class="text-lg font-semibold text-base-content">Configuração de SLA por Prioridade</h3>
<p class="text-sm text-base-content/60">Defina tempos de resposta, conclusão e alertas.</p> <p class="text-sm text-base-content/60">Configure SLAs separados para cada nível de prioridade</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{#if slaConfigsQuery?.data} <button class="btn btn-sm btn-primary" type="button" onclick={novoSla}>
{#each slaConfigsQuery.data as sla (sla._id)} Novo SLA
<button class="btn btn-sm" type="button" onclick={() => selecionarSla(sla)}>
{sla.nome}
</button> </button>
{/each}
{/if}
</div> </div>
</div> </div>
<div class="mt-6 grid gap-4 md:grid-cols-2"> <!-- Lista de SLAs por prioridade -->
<div class="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each ["baixa", "media", "alta", "critica"] as prioridade}
{@const slaAtual = slaConfigsPorPrioridade[prioridade]}
<div class="rounded-2xl border border-base-200 bg-base-100/50 p-4">
<div class="mb-2 flex items-center justify-between">
<h4 class="font-semibold capitalize text-base-content">{prioridade}</h4>
{#if slaAtual}
<span class="badge badge-success badge-sm">Configurado</span>
{:else}
<span class="badge badge-warning badge-sm">Não configurado</span>
{/if}
</div>
{#if slaAtual}
<div class="space-y-2 text-xs">
<div>
<span class="text-base-content/60">Resposta:</span>
<span class="font-semibold">{slaAtual.tempoRespostaHoras}h</span>
</div>
<div>
<span class="text-base-content/60">Conclusão:</span>
<span class="font-semibold">{slaAtual.tempoConclusaoHoras}h</span>
</div>
{#if slaAtual.tempoEncerramentoHoras}
<div>
<span class="text-base-content/60">Auto-encerramento:</span>
<span class="font-semibold">{slaAtual.tempoEncerramentoHoras}h</span>
</div>
{/if}
</div>
<div class="mt-3 flex gap-1">
<button
class="btn btn-xs btn-ghost"
type="button"
onclick={() => selecionarSla(slaAtual)}
>
Editar
</button>
<button
class="btn btn-xs btn-error btn-ghost"
type="button"
onclick={() => (slaParaExcluir = slaAtual._id)}
>
Excluir
</button>
</div>
{:else}
<button
class="btn btn-xs btn-primary btn-outline mt-2 w-full"
type="button"
onclick={() => {
slaForm.prioridade = prioridade as "baixa" | "media" | "alta" | "critica";
novoSla();
}}
>
Configurar
</button>
{/if}
</div>
{/each}
</div>
<!-- Formulário de SLA -->
<div class="mt-6 rounded-2xl border border-base-300 bg-base-100/90 p-4">
<h4 class="mb-4 font-semibold text-base-content">
{slaForm.slaId ? "Editar" : "Novo"} SLA - Prioridade {slaForm.prioridade.charAt(0).toUpperCase() + slaForm.prioridade.slice(1)}
</h4>
<div class="grid gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-semibold">Nome</span></label> <label class="label"><span class="label-text font-semibold">Nome *</span></label>
<input class="input input-bordered w-full" bind:value={slaForm.nome} /> <input class="input input-bordered w-full" bind:value={slaForm.nome} placeholder="Ex: SLA Média" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-semibold">Prioridade *</span></label>
<select class="select select-bordered w-full" bind:value={slaForm.prioridade}>
<option value="baixa">Baixa</option>
<option value="media">Média</option>
<option value="alta">Alta</option>
<option value="critica">Crítica</option>
</select>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-semibold">Descrição</span></label> <label class="label"><span class="label-text font-semibold">Descrição</span></label>
<input class="input input-bordered w-full" bind:value={slaForm.descricao} /> <input class="input input-bordered w-full" bind:value={slaForm.descricao} placeholder="Descrição opcional" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-semibold">Tempo de resposta (h)</span></label> <label class="label"><span class="label-text font-semibold">Tempo de resposta (h) *</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoRespostaHoras} /> <input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoRespostaHoras} />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-semibold">Tempo de conclusão (h)</span></label> <label class="label"><span class="label-text font-semibold">Tempo de conclusão (h) *</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoConclusaoHoras} /> <input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoConclusaoHoras} />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-semibold">Auto-encerramento (h)</span></label> <label class="label"><span class="label-text font-semibold">Auto-encerramento (h)</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoEncerramentoHoras} /> <input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoEncerramentoHoras} placeholder="Opcional" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-semibold">Alerta antes do vencimento (h)</span></label> <label class="label"><span class="label-text font-semibold">Alerta antes do vencimento (h) *</span></label>
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.alertaAntecedenciaHoras} /> <input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.alertaAntecedenciaHoras} />
</div> </div>
<div class="form-control md:col-span-2">
<label class="label cursor-pointer gap-3"> <label class="label cursor-pointer gap-3">
<span class="label-text font-semibold">Ativo</span> <span class="label-text font-semibold">Ativo</span>
<input type="checkbox" class="toggle toggle-primary" bind:checked={slaForm.ativo} /> <input type="checkbox" class="toggle toggle-primary" bind:checked={slaForm.ativo} />
</label> </label>
</div> </div>
</div>
{#if slaFeedback} {#if slaFeedback}
<p class="text-sm text-base-content/70 mt-3">{slaFeedback}</p> <div class="mt-3">
<p class="text-sm {slaFeedback.includes('sucesso') ? 'text-success' : 'text-error'}">{slaFeedback}</p>
</div>
{/if} {/if}
<button class="btn btn-primary mt-4" type="button" onclick={salvarSlaConfig}> <div class="mt-4 flex gap-2">
Salvar configuração <button class="btn btn-primary" type="button" onclick={salvarSlaConfig}>
{slaForm.slaId ? "Atualizar" : "Criar"} SLA
</button> </button>
{#if slaForm.slaId}
<button class="btn btn-ghost" type="button" onclick={novoSla}>
Cancelar
</button>
{/if}
</div>
</div>
</section>
<!-- Modal de confirmação de exclusão -->
{#if slaParaExcluir}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Confirmar exclusão</h3>
<p class="py-4">Tem certeza que deseja excluir esta configuração de SLA? Esta ação não pode ser desfeita.</p>
<div class="modal-action">
<button class="btn btn-ghost" type="button" onclick={() => (slaParaExcluir = null)}>
Cancelar
</button>
<button class="btn btn-error" type="button" onclick={excluirSlaConfig}>
Excluir
</button>
</div>
</div>
</div>
{/if}
<!-- Templates de Email -->
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
<div>
<h3 class="text-lg font-semibold text-base-content">Templates de Email - Chamados</h3>
<p class="text-sm text-base-content/60">Templates automáticos usados nas notificações de chamados.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#if carregandoTemplates}
<div class="col-span-full text-center text-sm text-base-content/60">
<span class="loading loading-spinner loading-md"></span>
<p class="mt-2">Carregando templates...</p>
</div>
{:else if templatesChamados.length === 0}
<div class="col-span-full rounded-2xl border border-base-300 bg-base-200/50 p-6 text-center">
<p class="text-sm font-semibold text-base-content/70">Nenhum template encontrado</p>
<p class="mt-2 text-xs text-base-content/50 mb-4">
Os templates de chamados serão criados automaticamente quando o sistema for inicializado.
Clique no botão abaixo para criar os templates padrão agora.
</p>
{#if templatesFeedback}
<div class={`alert ${templatesFeedback.includes('sucesso') ? 'alert-success' : 'alert-error'} mb-4`}>
<span class="text-sm">{templatesFeedback}</span>
</div>
{/if}
<button
class="btn btn-primary"
type="button"
onclick={criarTemplatesPadrao}
disabled={criandoTemplates}
>
{#if criandoTemplates}
<span class="loading loading-spinner loading-sm"></span>
Criando templates...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Criar templates padrão
{/if}
</button>
</div>
{:else}
{#each templatesChamados as template (template._id)}
<div class="rounded-2xl border border-base-200 bg-base-100/50 p-4">
<div class="mb-2 flex items-center justify-between">
<h4 class="font-semibold text-base-content">{template.nome}</h4>
<span class="badge badge-primary badge-sm">Sistema</span>
</div>
<p class="mb-3 text-xs text-base-content/60">{template.titulo}</p>
{#if template.variaveis && template.variaveis.length > 0}
<div class="mb-2">
<p class="text-xs font-semibold text-base-content/60">Variáveis:</p>
<div class="mt-1 flex flex-wrap gap-1">
{#each template.variaveis as variavel}
<span class="badge badge-outline badge-xs">{{variavel}}</span>
{/each}
</div>
</div>
{/if}
<div class="mt-3 text-xs text-base-content/50">
<p>Código: <code class="bg-base-200 px-1 py-0.5 rounded">{template.codigo || "N/A"}</code></p>
</div>
</div>
{/each}
{:else}
<div class="col-span-full text-center text-sm text-base-content/60">
<p>Carregando templates...</p>
</div>
{/if}
</div>
</section> </section>
</main> </main>

View File

@@ -100,14 +100,13 @@ function montarTimeline(base: number, prazos: ReturnType<typeof calcularPrazos>)
return timeline; return timeline;
} }
async function selecionarSlaConfig(ctx: Parameters<typeof getCurrentUserFunction>[0], slaConfigId?: Id<"slaConfigs">) { async function selecionarSlaConfig(
if (slaConfigId) { ctx: Parameters<typeof getCurrentUserFunction>[0],
return await ctx.db.get(slaConfigId); prioridade: "baixa" | "media" | "alta" | "critica"
} ): Promise<Doc<"slaConfigs"> | null> {
return await ctx.db return await ctx.db
.query("slaConfigs") .query("slaConfigs")
.withIndex("by_ativo", (q) => q.eq("ativo", true)) .withIndex("by_prioridade", (q) => q.eq("prioridade", prioridade).eq("ativo", true))
.first(); .first();
} }
@@ -122,6 +121,7 @@ async function registrarNotificacoes(
) { ) {
const { ticket, titulo, mensagem, usuarioEvento } = params; const { ticket, titulo, mensagem, usuarioEvento } = params;
// Notificar solicitante
if (ticket.solicitanteEmail) { if (ticket.solicitanteEmail) {
await ctx.runMutation(api.email.enfileirarEmail, { await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail, destinatario: ticket.solicitanteEmail,
@@ -142,6 +142,31 @@ async function registrarNotificacoes(
lida: false, lida: false,
criadaEm: Date.now(), criadaEm: Date.now(),
}); });
// Notificar responsável (se houver)
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`,
enviadoPor: usuarioEvento,
});
}
await ctx.db.insert("notificacoes", {
usuarioId: ticket.responsavelId,
tipo: "nova_mensagem",
...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}),
remetenteId: usuarioEvento,
titulo,
descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem,
lida: false,
criadaEm: Date.now(),
});
}
} }
async function registrarInteracao( async function registrarInteracao(
@@ -185,7 +210,6 @@ export const abrirChamado = mutation({
categoria: v.optional(v.string()), categoria: v.optional(v.string()),
prioridade: prioridadeValidator, prioridade: prioridadeValidator,
anexos: v.optional(v.array(arquivoValidator)), anexos: v.optional(v.array(arquivoValidator)),
slaConfigId: v.optional(v.id("slaConfigs")),
canalOrigem: v.optional(v.string()), canalOrigem: v.optional(v.string()),
}, },
returns: v.object({ returns: v.object({
@@ -195,7 +219,7 @@ export const abrirChamado = mutation({
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await assertAuth(ctx); const usuario = await assertAuth(ctx);
const agora = Date.now(); const agora = Date.now();
const sla = await selecionarSlaConfig(ctx, args.slaConfigId); const sla = await selecionarSlaConfig(ctx, args.prioridade);
const prazos = calcularPrazos(agora, sla); const prazos = calcularPrazos(agora, sla);
const timeline = montarTimeline(agora, prazos); const timeline = montarTimeline(agora, prazos);
@@ -486,7 +510,7 @@ export const salvarSlaConfig = mutation({
slaId: v.optional(v.id("slaConfigs")), slaId: v.optional(v.id("slaConfigs")),
nome: v.string(), nome: v.string(),
descricao: v.optional(v.string()), descricao: v.optional(v.string()),
setores: v.optional(v.array(v.string())), prioridade: prioridadeValidator,
tempoRespostaHoras: v.number(), tempoRespostaHoras: v.number(),
tempoConclusaoHoras: v.number(), tempoConclusaoHoras: v.number(),
tempoEncerramentoHoras: v.optional(v.number()), tempoEncerramentoHoras: v.optional(v.number()),
@@ -501,7 +525,7 @@ export const salvarSlaConfig = mutation({
await ctx.db.patch(args.slaId, { await ctx.db.patch(args.slaId, {
nome: args.nome, nome: args.nome,
descricao: args.descricao, descricao: args.descricao,
setores: args.setores, prioridade: args.prioridade,
tempoRespostaHoras: args.tempoRespostaHoras, tempoRespostaHoras: args.tempoRespostaHoras,
tempoConclusaoHoras: args.tempoConclusaoHoras, tempoConclusaoHoras: args.tempoConclusaoHoras,
tempoEncerramentoHoras: args.tempoEncerramentoHoras, tempoEncerramentoHoras: args.tempoEncerramentoHoras,
@@ -516,7 +540,7 @@ export const salvarSlaConfig = mutation({
return await ctx.db.insert("slaConfigs", { return await ctx.db.insert("slaConfigs", {
nome: args.nome, nome: args.nome,
descricao: args.descricao, descricao: args.descricao,
setores: args.setores, prioridade: args.prioridade,
tempoRespostaHoras: args.tempoRespostaHoras, tempoRespostaHoras: args.tempoRespostaHoras,
tempoConclusaoHoras: args.tempoConclusaoHoras, tempoConclusaoHoras: args.tempoConclusaoHoras,
tempoEncerramentoHoras: args.tempoEncerramentoHoras, tempoEncerramentoHoras: args.tempoEncerramentoHoras,
@@ -530,6 +554,102 @@ export const salvarSlaConfig = mutation({
}, },
}); });
export const excluirSlaConfig = mutation({
args: {
slaId: v.id("slaConfigs"),
},
handler: async (ctx, args) => {
await assertAuth(ctx);
const sla = await ctx.db.get(args.slaId);
if (!sla) {
throw new Error("Configuração de SLA não encontrada");
}
await ctx.db.delete(args.slaId);
return { sucesso: true };
},
});
export const prorrogarChamado = mutation({
args: {
ticketId: v.id("tickets"),
horasAdicionais: v.number(),
prazo: v.union(v.literal("resposta"), v.literal("conclusao")),
motivo: v.string(),
},
handler: async (ctx, args) => {
const usuario = await assertAuth(ctx);
const ticket = await ctx.db.get(args.ticketId);
if (!ticket) {
throw new Error("Chamado não encontrado");
}
const agora = Date.now();
const horasMs = args.horasAdicionais * 60 * 60 * 1000;
let novoPrazoResposta = ticket.prazoResposta;
let novoPrazoConclusao = ticket.prazoConclusao;
let prazoExtendido: number;
if (args.prazo === "resposta") {
prazoExtendido = (ticket.prazoResposta || agora) + horasMs;
novoPrazoResposta = prazoExtendido;
// Se o prazo de conclusão é antes do novo prazo de resposta, ajuste-o também
if (ticket.prazoConclusao && ticket.prazoConclusao < prazoExtendido) {
novoPrazoConclusao = prazoExtendido + (ticket.prazoConclusao - (ticket.prazoResposta || agora));
}
} else {
prazoExtendido = (ticket.prazoConclusao || agora) + horasMs;
novoPrazoConclusao = prazoExtendido;
}
// Atualizar timeline
const timelineAtualizada = ticket.timeline?.map((etapa) => {
if (args.prazo === "resposta" && etapa.etapa === "resposta_inicial") {
return {
...etapa,
prazo: prazoExtendido,
status: prazoExtendido > agora ? "pendente" : etapa.status,
};
}
if (args.prazo === "conclusao" && etapa.etapa === "conclusao") {
return {
...etapa,
prazo: prazoExtendido,
status: prazoExtendido > agora ? "pendente" : etapa.status,
};
}
return etapa;
}) || ticket.timeline;
await ctx.db.patch(ticket._id, {
prazoResposta: novoPrazoResposta,
prazoConclusao: novoPrazoConclusao,
timeline: timelineAtualizada,
atualizadoEm: agora,
ultimaInteracaoEm: agora,
});
await registrarInteracao(ctx, {
ticketId: ticket._id,
autorId: usuario._id,
origem: "ti",
tipo: "status",
conteudo: `Prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} prorrogado em ${args.horasAdicionais}h. Motivo: ${args.motivo}`,
});
const ticketAtualizado = await ctx.db.get(ticket._id);
if (ticketAtualizado) {
await registrarNotificacoes(ctx, {
ticket: ticketAtualizado,
titulo: `Prazo prorrogado - Chamado ${ticketAtualizado.numero}`,
mensagem: `O prazo ${args.prazo === "resposta" ? "de resposta" : "de conclusão"} foi prorrogado em ${args.horasAdicionais} horas. Motivo: ${args.motivo}`,
usuarioEvento: usuario._id,
});
}
return { sucesso: true };
},
});
export const emitirAlertaPrazo = mutation({ export const emitirAlertaPrazo = mutation({
args: { args: {
ticketId: v.id("tickets"), ticketId: v.id("tickets"),

View File

@@ -913,7 +913,12 @@ export default defineSchema({
slaConfigs: defineTable({ slaConfigs: defineTable({
nome: v.string(), nome: v.string(),
descricao: v.optional(v.string()), descricao: v.optional(v.string()),
setores: v.optional(v.array(v.string())), prioridade: v.union(
v.literal("baixa"),
v.literal("media"),
v.literal("alta"),
v.literal("critica")
),
tempoRespostaHoras: v.number(), tempoRespostaHoras: v.number(),
tempoConclusaoHoras: v.number(), tempoConclusaoHoras: v.number(),
tempoEncerramentoHoras: v.optional(v.number()), tempoEncerramentoHoras: v.optional(v.number()),
@@ -925,6 +930,7 @@ export default defineSchema({
atualizadoEm: v.number(), atualizadoEm: v.number(),
}) })
.index("by_ativo", ["ativo"]) .index("by_ativo", ["ativo"])
.index("by_prioridade", ["prioridade", "ativo"])
.index("by_nome", ["nome"]), .index("by_nome", ["nome"]),
ticketAssignments: defineTable({ ticketAssignments: defineTable({

View File

@@ -287,6 +287,115 @@ export const criarTemplatesPadrao = mutation({
+ "</div></body></html>", + "</div></body></html>",
variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"], variaveis: ["remetente", "mensagem", "conversaId", "urlSistema"],
}, },
{
codigo: "chamado_registrado",
nome: "Chamado Registrado",
titulo: "Chamado {{numeroTicket}} registrado",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #2563EB;'>Chamado registrado com sucesso!</h2>"
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
+ "<p>Recebemos sua solicitação e iniciaremos o atendimento em breve.</p>"
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Categoria:</strong> {{categoria}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/perfil/chamados' "
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Acompanhar chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["solicitante", "numeroTicket", "prioridade", "categoria", "urlSistema"],
},
{
codigo: "chamado_atualizado",
nome: "Atualização no Chamado",
titulo: "Atualização no chamado {{numeroTicket}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #2563EB;'>Nova atualização no seu chamado</h2>"
+ "<p>Olá <strong>{{solicitante}}</strong>,</p>"
+ "<p>Há uma nova atualização no seu chamado:</p>"
+ "<div style='background-color: #EFF6FF; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Mensagem:</strong></p>"
+ "<p style='margin: 10px 0 0 0;'>{{mensagem}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/perfil/chamados' "
+ "style='background-color: #2563EB; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver detalhes"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["solicitante", "numeroTicket", "mensagem", "urlSistema"],
},
{
codigo: "chamado_atribuido",
nome: "Chamado Atribuído",
titulo: "Chamado {{numeroTicket}} atribuído",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #059669;'>Chamado atribuído</h2>"
+ "<p>Olá <strong>{{responsavel}}</strong>,</p>"
+ "<p>Um novo chamado foi atribuído para você:</p>"
+ "<div style='background-color: #ECFDF5; border-left: 4px solid #059669; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Solicitante:</strong> {{solicitante}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prioridade:</strong> {{prioridade}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Descrição:</strong> {{descricao}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}/ti/central-chamados' "
+ "style='background-color: #059669; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Acessar chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["responsavel", "numeroTicket", "solicitante", "prioridade", "descricao", "urlSistema"],
},
{
codigo: "chamado_alerta_prazo",
nome: "Alerta de Prazo do Chamado",
titulo: "⚠️ Alerta de prazo - Chamado {{numeroTicket}}",
corpo: "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
+ "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
+ "<h2 style='color: #DC2626;'>⚠️ Alerta de prazo</h2>"
+ "<p>Olá <strong>{{destinatario}}</strong>,</p>"
+ "<p>O chamado abaixo está próximo do prazo de {{tipoPrazo}}:</p>"
+ "<div style='background-color: #FEF2F2; border-left: 4px solid #DC2626; padding: 15px; border-radius: 8px; margin: 20px 0;'>"
+ "<p style='margin: 0;'><strong>Ticket:</strong> {{numeroTicket}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Prazo de {{tipoPrazo}}:</strong> {{prazo}}</p>"
+ "<p style='margin: 5px 0 0 0;'><strong>Status:</strong> {{status}}</p>"
+ "</div>"
+ "<p style='margin-top: 30px;'>"
+ "<a href='{{urlSistema}}{{rotaAcesso}}' "
+ "style='background-color: #DC2626; color: white; padding: 12px 24px; "
+ "text-decoration: none; border-radius: 6px; display: inline-block;'>"
+ "Ver chamado"
+ "</a>"
+ "</p>"
+ "<p style='color: #6B7280; font-size: 12px; margin-top: 30px;'>"
+ "Central de Chamados SGSE"
+ "</p>"
+ "</div></body></html>",
variaveis: ["destinatario", "numeroTicket", "tipoPrazo", "prazo", "status", "urlSistema", "rotaAcesso"],
},
]; ];
for (const template of templatesPadrao) { for (const template of templatesPadrao) {