feat: enhance tickets portal and admin flows

This commit is contained in:
Esdras Renan 2025-10-07 02:26:09 -03:00
parent 9cdd8763b4
commit c15f0a5b09
67 changed files with 1101 additions and 338 deletions

View file

@ -5,6 +5,25 @@ import type { Id } from "./_generated/dataModel";
import { requireAdmin, requireStaff } from "./rbac";
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
};
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
@ -98,8 +117,14 @@ export const summary = query({
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
.collect();
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length;
const waiting = pending.filter((t) => {
const status = normalizeStatus(t.status);
return status === "PENDING" || status === "PAUSED";
}).length;
const open = pending.filter((t) => {
const status = normalizeStatus(t.status);
return status !== "RESOLVED" && status !== "CLOSED";
}).length;
const breached = 0;
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
})

View file

@ -5,12 +5,31 @@ import type { Doc, Id } from "./_generated/dataModel";
import { requireStaff } from "./rbac";
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
};
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
function average(values: number[]) {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
const OPEN_STATUSES = new Set(["NEW", "OPEN", "PENDING", "ON_HOLD"]);
const OPEN_STATUSES = new Set<TicketStatusNormalized>(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]);
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
function percentageChange(current: number, previous: number) {
@ -116,8 +135,11 @@ export const slaOverview = query({
const queues = await fetchQueues(ctx, tenantId);
const now = Date.now();
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const resolvedTickets = tickets.filter((ticket) => ticket.status === "RESOLVED" || ticket.status === "CLOSED");
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
const resolvedTickets = tickets.filter((ticket) => {
const status = normalizeStatus(ticket.status);
return status === "RESOLVED" || status === "CLOSED";
});
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const firstResponseTimes = tickets
@ -193,17 +215,18 @@ export const backlogOverview = query({
const viewer = await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
const statusCounts = tickets.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
const status = normalizeStatus(ticket.status);
acc[status] = (acc[status] ?? 0) + 1;
return acc;
}, {});
}, {} as Record<TicketStatusNormalized, number>);
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
return acc;
}, {});
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
const queueMap = new Map<string, { name: string; count: number }>();
for (const ticket of openTickets) {
@ -276,7 +299,7 @@ export const dashboardOverview = query({
const deltaMinutes =
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const surveys = await collectCsatSurveys(ctx, tickets);

View file

@ -144,6 +144,8 @@ export default defineSchema({
startedAt: v.number(),
stoppedAt: v.optional(v.number()),
durationMs: v.optional(v.number()),
pauseReason: v.optional(v.string()),
pauseNote: v.optional(v.string()),
})
.index("by_ticket", ["ticketId"])
.index("by_ticket_agent", ["ticketId", "agentId"]),

View file

@ -304,7 +304,7 @@ export const seedDemo = mutation({
reference: ++ref,
subject: "Erro 500 ao acessar portal do cliente",
summary: "Clientes relatam erro intermitente no portal web",
status: "OPEN",
status: "AWAITING_ATTENDANCE",
priority: "URGENT",
channel: "EMAIL",
queueId: queue1,
@ -362,7 +362,7 @@ export const seedDemo = mutation({
reference: ++ref,
subject: "Visita técnica para instalação de roteadores",
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
status: "OPEN",
status: "AWAITING_ATTENDANCE",
priority: "MEDIUM",
channel: "PHONE",
queueId: queueVisitas._id,
@ -387,4 +387,3 @@ export const seedDemo = mutation({
});
},
});

View file

@ -7,6 +7,38 @@ import { requireCustomer, requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
const PAUSE_REASON_LABELS: Record<string, string> = {
NO_CONTACT: "Falta de contato",
WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento",
};
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
};
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
OPEN: "AWAITING_ATTENDANCE",
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
};
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
if (!status) return "PENDING";
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
return normalized ?? "PENDING";
}
async function ensureManagerTicketAccess(
ctx: MutationCtx | QueryCtx,
@ -232,11 +264,6 @@ export const list = query({
.query("tickets")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
.collect();
} else if (args.status) {
base = await ctx.db
.query("tickets")
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
.collect();
} else if (args.queueId) {
base = await ctx.db
.query("tickets")
@ -258,9 +285,13 @@ export const list = query({
}
filtered = filtered.filter((t) => t.companyId === user.companyId)
}
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
if (normalizedStatusFilter) {
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
}
if (args.search) {
const term = args.search.toLowerCase();
filtered = filtered.filter(
@ -307,7 +338,7 @@ export const list = query({
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: t.status,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
@ -428,7 +459,7 @@ export const getById = query({
tenantId: t.tenantId,
subject: t.subject,
summary: t.summary,
status: t.status,
status: normalizeStatus(t.status),
priority: t.priority,
channel: t.channel,
queue: queueName,
@ -588,12 +619,13 @@ export const create = mutation({
.take(1);
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
const now = Date.now();
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId,
reference: nextRef,
subject,
summary: args.summary?.trim() || undefined,
status: "NEW",
status: initialStatus,
priority: args.priority,
channel: args.channel,
queueId: args.queueId,
@ -847,20 +879,13 @@ export const updateStatus = mutation({
}
const ticketDoc = ticket as Doc<"tickets">
await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedStatus = normalizeStatus(status)
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
} as const;
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: status, toLabel: statusPt[status] ?? status, actorId },
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
createdAt: now,
});
},
@ -1095,8 +1120,13 @@ export const startWork = mutation({
})
export const pauseWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
reason: v.string(),
note: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, reason, note }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
@ -1106,6 +1136,10 @@ export const pauseWork = mutation({
return { status: "already_paused" }
}
if (!PAUSE_REASON_LABELS[reason]) {
throw new ConvexError("Motivo de pausa inválido")
}
const session = await ctx.db.get(ticket.activeSessionId)
if (!session) {
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
@ -1118,6 +1152,8 @@ export const pauseWork = mutation({
await ctx.db.patch(ticket.activeSessionId, {
stoppedAt: now,
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
})
await ctx.db.patch(ticketId, {
@ -1137,11 +1173,19 @@ export const pauseWork = mutation({
actorAvatar: actor?.avatarUrl,
sessionId: session._id,
sessionDurationMs: durationMs,
pauseReason: reason,
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
pauseNote: note ?? "",
},
createdAt: now,
})
return { status: "paused", durationMs }
return {
status: "paused",
durationMs,
pauseReason: reason,
pauseNote: note ?? "",
}
},
})
@ -1228,7 +1272,10 @@ export const playNext = mutation({
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const currentStatus = normalizeStatus(chosen.status);
const nextStatus: TicketStatusNormalized =
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: nextStatus, updatedAt: now });
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
@ -1247,7 +1294,7 @@ export const playNext = mutation({
tenantId: chosen.tenantId,
subject: chosen.subject,
summary: chosen.summary,
status: chosen.status,
status: nextStatus,
priority: chosen.priority,
channel: chosen.channel,
queue: queueName,