feat: enhance tickets portal and admin flows
This commit is contained in:
parent
9cdd8763b4
commit
c15f0a5b09
67 changed files with 1101 additions and 338 deletions
|
|
@ -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 };
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue