Merge branch 'feat-central-chamados' into feat-cibersecurity

This commit is contained in:
2025-11-17 10:19:10 -03:00
11 changed files with 1574 additions and 188 deletions

View File

@@ -100,14 +100,13 @@ function montarTimeline(base: number, prazos: ReturnType<typeof calcularPrazos>)
return timeline;
}
async function selecionarSlaConfig(ctx: Parameters<typeof getCurrentUserFunction>[0], slaConfigId?: Id<"slaConfigs">) {
if (slaConfigId) {
return await ctx.db.get(slaConfigId);
}
async function selecionarSlaConfig(
ctx: Parameters<typeof getCurrentUserFunction>[0],
prioridade: "baixa" | "media" | "alta" | "critica"
): Promise<Doc<"slaConfigs"> | null> {
return await ctx.db
.query("slaConfigs")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.withIndex("by_prioridade", (q) => q.eq("prioridade", prioridade).eq("ativo", true))
.first();
}
@@ -122,6 +121,7 @@ async function registrarNotificacoes(
) {
const { ticket, titulo, mensagem, usuarioEvento } = params;
// Notificar solicitante
if (ticket.solicitanteEmail) {
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: ticket.solicitanteEmail,
@@ -142,6 +142,31 @@ async function registrarNotificacoes(
lida: false,
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(
@@ -185,7 +210,6 @@ export const abrirChamado = mutation({
categoria: v.optional(v.string()),
prioridade: prioridadeValidator,
anexos: v.optional(v.array(arquivoValidator)),
slaConfigId: v.optional(v.id("slaConfigs")),
canalOrigem: v.optional(v.string()),
},
returns: v.object({
@@ -195,7 +219,7 @@ export const abrirChamado = mutation({
handler: async (ctx, args) => {
const usuario = await assertAuth(ctx);
const agora = Date.now();
const sla = await selecionarSlaConfig(ctx, args.slaConfigId);
const sla = await selecionarSlaConfig(ctx, args.prioridade);
const prazos = calcularPrazos(agora, sla);
const timeline = montarTimeline(agora, prazos);
@@ -304,8 +328,23 @@ export const listarChamadosTI = query({
return true;
});
filtrados.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
return args.limite ? filtrados.slice(0, args.limite) : filtrados;
// Enriquecer tickets com nome do responsável
const ticketsEnriquecidos = await Promise.all(
filtrados.map(async (ticket) => {
let responsavelNome: string | undefined = undefined;
if (ticket.responsavelId) {
const responsavel = await ctx.db.get(ticket.responsavelId);
responsavelNome = responsavel?.nome;
}
return {
...ticket,
responsavelNome,
};
})
);
ticketsEnriquecidos.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
return args.limite ? ticketsEnriquecidos.slice(0, args.limite) : ticketsEnriquecidos;
},
});
@@ -481,12 +520,108 @@ export const listarSlaConfigs = query({
},
});
export const obterEstatisticasChamados = query({
args: {},
handler: async (ctx) => {
await assertAuth(ctx);
const todosTickets = await ctx.db.query("tickets").collect();
const total = todosTickets.length;
const abertos = todosTickets.filter((t) => t.status === "aberto").length;
const emAndamento = todosTickets.filter((t) => t.status === "em_andamento").length;
const vencidos = todosTickets.filter(
(t) => (t.prazoConclusao && t.prazoConclusao < Date.now()) || t.status === "cancelado"
).length;
return { total, abertos, emAndamento, vencidos };
},
});
export const obterDadosSlaGrafico = query({
args: {},
handler: async (ctx) => {
await assertAuth(ctx);
const agora = Date.now();
const todosTickets = await ctx.db.query("tickets").collect();
const slaConfigs = await ctx.db.query("slaConfigs").collect();
// Agrupar SLAs por prioridade
const slaPorPrioridade = new Map<string, Doc<"slaConfigs">>();
slaConfigs.filter(s => s.ativo).forEach(sla => {
if (sla.prioridade) {
slaPorPrioridade.set(sla.prioridade, sla);
}
});
// Calcular status de SLA para cada ticket
const statusSla = {
dentroPrazo: 0,
proximoVencimento: 0,
vencido: 0,
semPrazo: 0,
};
const porPrioridade = {
baixa: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
media: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
alta: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
critica: { dentroPrazo: 0, proximoVencimento: 0, vencido: 0, total: 0 },
};
todosTickets.forEach(ticket => {
if (!ticket.prazoConclusao) {
statusSla.semPrazo++;
return;
}
const prazoConclusao = ticket.prazoConclusao;
const horasRestantes = (prazoConclusao - agora) / (1000 * 60 * 60);
const sla = slaPorPrioridade.get(ticket.prioridade);
const alertaHoras = sla?.alertaAntecedenciaHoras ?? 2;
if (prazoConclusao < agora) {
statusSla.vencido++;
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].vencido++;
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
}
} else if (horasRestantes <= alertaHoras) {
statusSla.proximoVencimento++;
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].proximoVencimento++;
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
}
} else {
statusSla.dentroPrazo++;
if (ticket.prioridade && porPrioridade[ticket.prioridade as keyof typeof porPrioridade]) {
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].dentroPrazo++;
porPrioridade[ticket.prioridade as keyof typeof porPrioridade].total++;
}
}
});
// Calcular taxa de cumprimento
const totalComPrazo = statusSla.dentroPrazo + statusSla.proximoVencimento + statusSla.vencido;
const taxaCumprimento = totalComPrazo > 0
? Math.round((statusSla.dentroPrazo / totalComPrazo) * 100)
: 100;
return {
statusSla,
porPrioridade,
taxaCumprimento,
totalComPrazo,
atualizadoEm: agora,
};
},
});
export const salvarSlaConfig = mutation({
args: {
slaId: v.optional(v.id("slaConfigs")),
nome: v.string(),
descricao: v.optional(v.string()),
setores: v.optional(v.array(v.string())),
prioridade: prioridadeValidator,
tempoRespostaHoras: v.number(),
tempoConclusaoHoras: v.number(),
tempoEncerramentoHoras: v.optional(v.number()),
@@ -501,7 +636,7 @@ export const salvarSlaConfig = mutation({
await ctx.db.patch(args.slaId, {
nome: args.nome,
descricao: args.descricao,
setores: args.setores,
prioridade: args.prioridade,
tempoRespostaHoras: args.tempoRespostaHoras,
tempoConclusaoHoras: args.tempoConclusaoHoras,
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
@@ -516,7 +651,7 @@ export const salvarSlaConfig = mutation({
return await ctx.db.insert("slaConfigs", {
nome: args.nome,
descricao: args.descricao,
setores: args.setores,
prioridade: args.prioridade,
tempoRespostaHoras: args.tempoRespostaHoras,
tempoConclusaoHoras: args.tempoConclusaoHoras,
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
@@ -530,6 +665,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({
args: {
ticketId: v.id("tickets"),

View File

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

View File

@@ -287,6 +287,115 @@ export const criarTemplatesPadrao = mutation({
+ "</div></body></html>",
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) {