refactor: update menu and routing for ticket management
- Replaced references to "Solicitar Acesso" with "Abrir Chamado" across the application for consistency in terminology. - Updated routing logic to reflect the new ticket management flow, ensuring that the dashboard and sidebar components point to the correct paths. - Removed the obsolete "Solicitar Acesso" page, streamlining the user experience and reducing unnecessary navigation options. - Enhanced backend schema to support new ticket functionalities, including ticket creation and management.
This commit is contained in:
585
packages/backend/convex/chamados.ts
Normal file
585
packages/backend/convex/chamados.ts
Normal file
@@ -0,0 +1,585 @@
|
||||
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], slaConfigId?: Id<"slaConfigs">) {
|
||||
if (slaConfigId) {
|
||||
return await ctx.db.get(slaConfigId);
|
||||
}
|
||||
|
||||
return await ctx.db
|
||||
.query("slaConfigs")
|
||||
.withIndex("by_ativo", (q) => q.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;
|
||||
|
||||
if (ticket.solicitanteEmail) {
|
||||
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||
destinatario: ticket.solicitanteEmail,
|
||||
destinatarioId: ticket.solicitanteId,
|
||||
assunto: `${titulo} - Chamado ${ticket.numero}`,
|
||||
corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`,
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
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)),
|
||||
slaConfigId: v.optional(v.id("slaConfigs")),
|
||||
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.slaConfigId);
|
||||
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;
|
||||
});
|
||||
|
||||
filtrados.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
|
||||
return args.limite ? filtrados.slice(0, args.limite) : filtrados;
|
||||
},
|
||||
});
|
||||
|
||||
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 ||
|
||||
ticket.setorResponsavel === usuario.setor;
|
||||
|
||||
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,
|
||||
setorResponsavel: responsavel.setor,
|
||||
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 salvarSlaConfig = mutation({
|
||||
args: {
|
||||
slaId: v.optional(v.id("slaConfigs")),
|
||||
nome: v.string(),
|
||||
descricao: v.optional(v.string()),
|
||||
setores: v.optional(v.array(v.string())),
|
||||
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,
|
||||
setores: args.setores,
|
||||
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,
|
||||
setores: args.setores,
|
||||
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 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();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user