873 lines
24 KiB
TypeScript
873 lines
24 KiB
TypeScript
import { v } from 'convex/values';
|
|
import { mutation, query } from './_generated/server';
|
|
import type { MutationCtx } from './_generated/server';
|
|
import { api } from './_generated/api';
|
|
import { getCurrentUserFunction } from './auth';
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
|
|
const ticketStatusValidator = v.union(
|
|
v.literal('aberto'),
|
|
v.literal('em_andamento'),
|
|
v.literal('aguardando_usuario'),
|
|
v.literal('resolvido'),
|
|
v.literal('encerrado'),
|
|
v.literal('cancelado')
|
|
);
|
|
|
|
const ticketTipoValidator = v.union(
|
|
v.literal('reclamacao'),
|
|
v.literal('elogio'),
|
|
v.literal('sugestao'),
|
|
v.literal('chamado')
|
|
);
|
|
|
|
const prioridadeValidator = v.union(
|
|
v.literal('baixa'),
|
|
v.literal('media'),
|
|
v.literal('alta'),
|
|
v.literal('critica')
|
|
);
|
|
|
|
const arquivoValidator = v.object({
|
|
arquivoId: v.id('_storage'),
|
|
nome: v.optional(v.string()),
|
|
tipo: v.optional(v.string()),
|
|
tamanho: v.optional(v.number())
|
|
});
|
|
|
|
async function assertAuth(ctx: Parameters<typeof getCurrentUserFunction>[0]) {
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) {
|
|
throw new Error('Usuário não autenticado');
|
|
}
|
|
return usuario;
|
|
}
|
|
|
|
type TicketDoc = Doc<'tickets'>;
|
|
type SlaDoc = Doc<'slaConfigs'> | null;
|
|
|
|
function gerarNumeroTicket(): string {
|
|
const agora = new Date();
|
|
const ano = agora.getFullYear();
|
|
const mes = String(agora.getMonth() + 1).padStart(2, '0');
|
|
const sequencia = Math.floor(Math.random() * 9000 + 1000);
|
|
return `SGSE-${ano}${mes}-${sequencia}`;
|
|
}
|
|
|
|
function calcularPrazos(base: number, sla: SlaDoc) {
|
|
const horaMs = 60 * 60 * 1000;
|
|
|
|
const tempoResposta = sla?.tempoRespostaHoras ?? 4;
|
|
const tempoConclusao = sla?.tempoConclusaoHoras ?? 24;
|
|
const tempoEncerramento = sla?.tempoEncerramentoHoras ?? null;
|
|
|
|
return {
|
|
resposta: base + tempoResposta * horaMs,
|
|
conclusao: base + tempoConclusao * horaMs,
|
|
encerramento: tempoEncerramento ? base + tempoEncerramento * horaMs : null
|
|
};
|
|
}
|
|
|
|
function montarTimeline(base: number, prazos: ReturnType<typeof calcularPrazos>) {
|
|
const timeline: NonNullable<TicketDoc['timeline']> = [
|
|
{
|
|
etapa: 'abertura',
|
|
status: 'concluido',
|
|
prazo: base,
|
|
concluidoEm: base,
|
|
observacao: 'Chamado registrado com sucesso'
|
|
},
|
|
{
|
|
etapa: 'resposta_inicial',
|
|
status: 'pendente',
|
|
prazo: prazos.resposta
|
|
},
|
|
{
|
|
etapa: 'conclusao',
|
|
status: 'pendente',
|
|
prazo: prazos.conclusao
|
|
}
|
|
];
|
|
|
|
if (prazos.encerramento) {
|
|
timeline.push({
|
|
etapa: 'encerramento',
|
|
status: 'pendente',
|
|
prazo: prazos.encerramento
|
|
});
|
|
}
|
|
|
|
return timeline;
|
|
}
|
|
|
|
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_prioridade', (q) => q.eq('prioridade', prioridade).eq('ativo', true))
|
|
.first();
|
|
}
|
|
|
|
async function registrarNotificacoes(
|
|
ctx: MutationCtx,
|
|
params: {
|
|
ticket: Doc<'tickets'>;
|
|
titulo: string;
|
|
mensagem: string;
|
|
usuarioEvento: Id<'usuarios'>;
|
|
}
|
|
) {
|
|
const { ticket, titulo, mensagem, usuarioEvento } = params;
|
|
|
|
// Obter URL do sistema
|
|
let urlSistema = process.env.SITE_URL || 'http://localhost:5173';
|
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
|
urlSistema = `http://${urlSistema}`;
|
|
}
|
|
|
|
// Notificar solicitante
|
|
if (ticket.solicitanteEmail) {
|
|
// Tentar usar template, senão usar envio direto
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: ticket.solicitanteEmail,
|
|
destinatarioId: ticket.solicitanteId,
|
|
templateCodigo: 'chamado_atualizado',
|
|
variaveis: {
|
|
solicitante: ticket.solicitanteNome || 'Usuário',
|
|
numeroTicket: ticket.numero,
|
|
mensagem: mensagem,
|
|
urlSistema
|
|
},
|
|
enviadoPor: usuarioEvento
|
|
});
|
|
} catch (error) {
|
|
// Fallback para envio direto
|
|
console.warn(
|
|
'Erro ao agendar envio de email com template chamado_atualizado para solicitante, usando envio direto:',
|
|
error
|
|
);
|
|
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', {
|
|
usuarioId: ticket.solicitanteId,
|
|
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()
|
|
});
|
|
|
|
// Se o ticket estiver associado a uma conversa, registrar também uma mensagem de chat
|
|
// Isso garante o "duplo canal": email + chat para notificações importantes.
|
|
if (ticket.conversaId) {
|
|
const conteudoChat = mensagem.length > 0 ? `${titulo}: ${mensagem}` : titulo;
|
|
|
|
await ctx.db.insert('mensagens', {
|
|
conversaId: ticket.conversaId,
|
|
remetenteId: usuarioEvento,
|
|
tipo: 'texto',
|
|
conteudo: conteudoChat,
|
|
enviadaEm: 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) {
|
|
// Tentar usar template, senão usar envio direto
|
|
try {
|
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
destinatario: responsavel.email,
|
|
destinatarioId: ticket.responsavelId,
|
|
templateCodigo: 'chamado_atualizado',
|
|
variaveis: {
|
|
solicitante: ticket.solicitanteNome || 'Usuário',
|
|
numeroTicket: ticket.numero,
|
|
mensagem: mensagem,
|
|
urlSistema
|
|
},
|
|
enviadoPor: usuarioEvento
|
|
});
|
|
} catch (error) {
|
|
// Fallback para envio direto
|
|
console.warn(
|
|
'Erro ao agendar envio de email com template chamado_atualizado para responsável, usando envio direto:',
|
|
error
|
|
);
|
|
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', {
|
|
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(
|
|
ctx: MutationCtx,
|
|
params: {
|
|
ticketId: Id<'tickets'>;
|
|
autorId: Id<'usuarios'> | null;
|
|
origem: 'usuario' | 'ti' | 'sistema';
|
|
tipo: 'mensagem' | 'status' | 'anexo' | 'alerta';
|
|
conteudo: string;
|
|
visibilidade?: 'publico' | 'interno';
|
|
anexos?: Array<{
|
|
arquivoId: Id<'_storage'>;
|
|
nome?: string;
|
|
tipo?: string;
|
|
tamanho?: number;
|
|
}>;
|
|
statusAnterior?: TicketDoc['status'];
|
|
statusNovo?: TicketDoc['status'];
|
|
}
|
|
) {
|
|
return await ctx.db.insert('ticketInteractions', {
|
|
ticketId: params.ticketId,
|
|
autorId: params.autorId || undefined,
|
|
origem: params.origem,
|
|
tipo: params.tipo,
|
|
conteudo: params.conteudo,
|
|
visibilidade: params.visibilidade ?? 'publico',
|
|
anexos: params.anexos,
|
|
statusAnterior: params.statusAnterior,
|
|
statusNovo: params.statusNovo,
|
|
criadoEm: Date.now()
|
|
});
|
|
}
|
|
|
|
export const abrirChamado = mutation({
|
|
args: {
|
|
titulo: v.string(),
|
|
descricao: v.string(),
|
|
tipo: ticketTipoValidator,
|
|
categoria: v.optional(v.string()),
|
|
prioridade: prioridadeValidator,
|
|
anexos: v.optional(v.array(arquivoValidator)),
|
|
canalOrigem: v.optional(v.string())
|
|
},
|
|
returns: v.object({
|
|
ticketId: v.id('tickets'),
|
|
numero: v.string()
|
|
}),
|
|
handler: async (ctx, args) => {
|
|
const usuario = await assertAuth(ctx);
|
|
const agora = Date.now();
|
|
const sla = await selecionarSlaConfig(ctx, args.prioridade);
|
|
const prazos = calcularPrazos(agora, sla);
|
|
const timeline = montarTimeline(agora, prazos);
|
|
|
|
const ticketId = await ctx.db.insert('tickets', {
|
|
numero: gerarNumeroTicket(),
|
|
titulo: args.titulo.trim(),
|
|
descricao: args.descricao.trim(),
|
|
tipo: args.tipo,
|
|
categoria: args.categoria,
|
|
status: 'aberto',
|
|
prioridade: args.prioridade,
|
|
solicitanteId: usuario._id,
|
|
solicitanteNome: usuario.nome,
|
|
solicitanteEmail: usuario.email,
|
|
responsavelId: undefined,
|
|
setorResponsavel: undefined,
|
|
slaConfigId: sla?._id,
|
|
conversaId: undefined,
|
|
prazoResposta: prazos.resposta,
|
|
prazoConclusao: prazos.conclusao,
|
|
prazoEncerramento: prazos.encerramento ?? undefined,
|
|
timeline,
|
|
alertasEmitidos: [],
|
|
anexos: args.anexos,
|
|
tags: undefined,
|
|
canalOrigem: args.canalOrigem,
|
|
ultimaInteracaoEm: agora,
|
|
criadoEm: agora,
|
|
atualizadoEm: agora
|
|
});
|
|
|
|
await registrarInteracao(ctx, {
|
|
ticketId,
|
|
autorId: usuario._id,
|
|
origem: 'usuario',
|
|
tipo: 'mensagem',
|
|
conteudo: args.descricao,
|
|
anexos: args.anexos
|
|
});
|
|
|
|
const ticket = await ctx.db.get(ticketId);
|
|
if (ticket) {
|
|
await registrarNotificacoes(ctx, {
|
|
ticket,
|
|
titulo: 'Chamado registrado',
|
|
mensagem: 'Recebemos sua solicitação e iniciaremos o atendimento em breve.',
|
|
usuarioEvento: usuario._id
|
|
});
|
|
}
|
|
|
|
return {
|
|
ticketId,
|
|
numero: ticket ? ticket.numero : ''
|
|
};
|
|
}
|
|
});
|
|
|
|
export const listarChamadosUsuario = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const usuario = await assertAuth(ctx);
|
|
|
|
const tickets = await ctx.db
|
|
.query('tickets')
|
|
.withIndex('by_solicitante', (q) => q.eq('solicitanteId', usuario._id))
|
|
.collect();
|
|
|
|
tickets.sort((a, b) => b.criadoEm - a.criadoEm);
|
|
return tickets;
|
|
}
|
|
});
|
|
|
|
export const listarChamadosTI = query({
|
|
args: {
|
|
status: v.optional(ticketStatusValidator),
|
|
responsavelId: v.optional(v.id('usuarios')),
|
|
setor: v.optional(v.string()),
|
|
limite: v.optional(v.number())
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// Permitir apenas usuários autenticados (regras detalhadas devem ser aplicadas no frontend)
|
|
await assertAuth(ctx);
|
|
|
|
let tickets: Array<Doc<'tickets'>> = [];
|
|
|
|
if (args.responsavelId) {
|
|
tickets = await ctx.db
|
|
.query('tickets')
|
|
.withIndex('by_responsavel', (q) =>
|
|
q.eq('responsavelId', args.responsavelId).eq('status', args.status ?? 'aberto')
|
|
)
|
|
.collect();
|
|
} else if (args.status) {
|
|
tickets = await ctx.db
|
|
.query('tickets')
|
|
.withIndex('by_status', (q) => q.eq('status', args.status!))
|
|
.collect();
|
|
} else {
|
|
tickets = await ctx.db.query('tickets').collect();
|
|
}
|
|
|
|
const filtrados = tickets.filter((ticket) => {
|
|
if (args.setor && ticket.setorResponsavel !== args.setor) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
|
|
export const obterChamado = query({
|
|
args: {
|
|
ticketId: v.id('tickets')
|
|
},
|
|
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 podeVer = ticket.solicitanteId === usuario._id || ticket.responsavelId === usuario._id;
|
|
|
|
if (!podeVer) {
|
|
throw new Error('Acesso negado ao chamado');
|
|
}
|
|
|
|
const interactions = await ctx.db
|
|
.query('ticketInteractions')
|
|
.withIndex('by_ticket', (q) => q.eq('ticketId', args.ticketId))
|
|
.collect();
|
|
|
|
interactions.sort((a, b) => a.criadoEm - b.criadoEm);
|
|
|
|
return {
|
|
ticket,
|
|
interactions
|
|
};
|
|
}
|
|
});
|
|
|
|
export const registrarAtualizacao = mutation({
|
|
args: {
|
|
ticketId: v.id('tickets'),
|
|
conteudo: v.string(),
|
|
anexos: v.optional(v.array(arquivoValidator)),
|
|
visibilidade: v.optional(v.union(v.literal('publico'), v.literal('interno'))),
|
|
proximoStatus: v.optional(ticketStatusValidator)
|
|
},
|
|
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();
|
|
let novoStatus = ticket.status;
|
|
|
|
if (args.proximoStatus && args.proximoStatus !== ticket.status) {
|
|
novoStatus = args.proximoStatus;
|
|
await ctx.db.patch(ticket._id, {
|
|
status: novoStatus,
|
|
atualizadoEm: agora,
|
|
ultimaInteracaoEm: agora
|
|
});
|
|
} else {
|
|
await ctx.db.patch(ticket._id, {
|
|
atualizadoEm: agora,
|
|
ultimaInteracaoEm: agora
|
|
});
|
|
}
|
|
|
|
await registrarInteracao(ctx, {
|
|
ticketId: ticket._id,
|
|
autorId: usuario._id,
|
|
origem: 'ti',
|
|
tipo: args.proximoStatus ? 'status' : 'mensagem',
|
|
conteudo: args.conteudo,
|
|
visibilidade: args.visibilidade,
|
|
anexos: args.anexos,
|
|
statusAnterior: args.proximoStatus ? ticket.status : undefined,
|
|
statusNovo: args.proximoStatus ? novoStatus : undefined
|
|
});
|
|
|
|
const ticketAtualizado = await ctx.db.get(ticket._id);
|
|
if (ticketAtualizado) {
|
|
await registrarNotificacoes(ctx, {
|
|
ticket: ticketAtualizado,
|
|
titulo: `Atualização no chamado ${ticketAtualizado.numero}`,
|
|
mensagem: args.conteudo,
|
|
usuarioEvento: usuario._id
|
|
});
|
|
}
|
|
|
|
return { status: novoStatus };
|
|
}
|
|
});
|
|
|
|
export const atribuirResponsavel = mutation({
|
|
args: {
|
|
ticketId: v.id('tickets'),
|
|
responsavelId: v.id('usuarios'),
|
|
motivo: v.optional(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 responsavel = await ctx.db.get(args.responsavelId);
|
|
if (!responsavel) {
|
|
throw new Error('Responsável inválido');
|
|
}
|
|
|
|
const agora = Date.now();
|
|
|
|
await ctx.db.patch(ticket._id, {
|
|
responsavelId: args.responsavelId,
|
|
atualizadoEm: agora
|
|
});
|
|
|
|
const assignmentsAtivos = await ctx.db
|
|
.query('ticketAssignments')
|
|
.withIndex('by_ticket', (q) => q.eq('ticketId', ticket._id).eq('ativo', true))
|
|
.collect();
|
|
|
|
for (const assignment of assignmentsAtivos) {
|
|
await ctx.db.patch(assignment._id, {
|
|
ativo: false,
|
|
encerradoEm: agora
|
|
});
|
|
}
|
|
|
|
await ctx.db.insert('ticketAssignments', {
|
|
ticketId: ticket._id,
|
|
responsavelId: args.responsavelId,
|
|
atribuidoPor: usuario._id,
|
|
motivo: args.motivo,
|
|
ativo: true,
|
|
criadoEm: agora,
|
|
encerradoEm: undefined
|
|
});
|
|
|
|
await registrarInteracao(ctx, {
|
|
ticketId: ticket._id,
|
|
autorId: usuario._id,
|
|
origem: 'ti',
|
|
tipo: 'status',
|
|
conteudo: `Chamado atribuído para ${responsavel.nome}`
|
|
});
|
|
|
|
const ticketAtualizado = await ctx.db.get(ticket._id);
|
|
if (ticketAtualizado) {
|
|
await registrarNotificacoes(ctx, {
|
|
ticket: ticketAtualizado,
|
|
titulo: 'Chamado atribuído',
|
|
mensagem: `Seu chamado agora está com ${responsavel.nome}.`,
|
|
usuarioEvento: usuario._id
|
|
});
|
|
}
|
|
|
|
return { responsavelId: args.responsavelId };
|
|
}
|
|
});
|
|
|
|
export const listarSlaConfigs = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
await assertAuth(ctx);
|
|
const slaConfigs = await ctx.db.query('slaConfigs').collect();
|
|
slaConfigs.sort((a, b) => b.criadoEm - a.criadoEm);
|
|
return slaConfigs;
|
|
}
|
|
});
|
|
|
|
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()),
|
|
prioridade: prioridadeValidator,
|
|
tempoRespostaHoras: v.number(),
|
|
tempoConclusaoHoras: v.number(),
|
|
tempoEncerramentoHoras: v.optional(v.number()),
|
|
alertaAntecedenciaHoras: v.number(),
|
|
ativo: v.boolean()
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const usuario = await assertAuth(ctx);
|
|
const agora = Date.now();
|
|
|
|
if (args.slaId) {
|
|
await ctx.db.patch(args.slaId, {
|
|
nome: args.nome,
|
|
descricao: args.descricao,
|
|
prioridade: args.prioridade,
|
|
tempoRespostaHoras: args.tempoRespostaHoras,
|
|
tempoConclusaoHoras: args.tempoConclusaoHoras,
|
|
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
|
|
alertaAntecedenciaHoras: args.alertaAntecedenciaHoras,
|
|
ativo: args.ativo,
|
|
atualizadoPor: usuario._id,
|
|
atualizadoEm: agora
|
|
});
|
|
return args.slaId;
|
|
}
|
|
|
|
return await ctx.db.insert('slaConfigs', {
|
|
nome: args.nome,
|
|
descricao: args.descricao,
|
|
prioridade: args.prioridade,
|
|
tempoRespostaHoras: args.tempoRespostaHoras,
|
|
tempoConclusaoHoras: args.tempoConclusaoHoras,
|
|
tempoEncerramentoHoras: args.tempoEncerramentoHoras,
|
|
alertaAntecedenciaHoras: args.alertaAntecedenciaHoras,
|
|
ativo: args.ativo,
|
|
criadoPor: usuario._id,
|
|
atualizadoPor: usuario._id,
|
|
criadoEm: agora,
|
|
atualizadoEm: agora
|
|
});
|
|
}
|
|
});
|
|
|
|
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'),
|
|
tipo: v.union(v.literal('resposta'), v.literal('conclusao'), v.literal('encerramento')),
|
|
mensagem: 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 atualizado = [
|
|
...(ticket.alertasEmitidos || []),
|
|
{ tipo: args.tipo, emitidoEm: Date.now() }
|
|
];
|
|
|
|
await ctx.db.patch(ticket._id, {
|
|
alertasEmitidos: atualizado
|
|
});
|
|
|
|
await registrarInteracao(ctx, {
|
|
ticketId: ticket._id,
|
|
autorId: usuario._id,
|
|
origem: 'sistema',
|
|
tipo: 'alerta',
|
|
conteudo: args.mensagem
|
|
});
|
|
|
|
await registrarNotificacoes(ctx, {
|
|
ticket,
|
|
titulo: `Alerta de SLA (${args.tipo})`,
|
|
mensagem: args.mensagem,
|
|
usuarioEvento: usuario._id
|
|
});
|
|
|
|
return { sucesso: true };
|
|
}
|
|
});
|
|
|
|
export const generateUploadUrl = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
await assertAuth(ctx);
|
|
return await ctx.storage.generateUploadUrl();
|
|
}
|
|
});
|