feat: refresh dashboards experience

This commit is contained in:
Esdras Renan 2025-11-06 01:40:10 -03:00
parent 1900f65e5e
commit d7d6b748cc
9 changed files with 1626 additions and 281 deletions

View file

@ -1,4 +1,4 @@
import { v } from "convex/values"
import { ConvexError, v } from "convex/values"
import type { Doc, Id } from "./_generated/dataModel"
import { query } from "./_generated/server"
@ -90,6 +90,178 @@ function filterTicketsByQueue<T extends { queueId?: Id<"queues"> | null }>(
})
}
type AgentStatsRaw = {
agentId: Id<"users">
name: string | null
email: string | null
open: number
paused: number
resolved: number
totalSla: number
compliantSla: number
resolutionMinutes: number[]
firstResponseMinutes: number[]
}
type AgentStatsComputed = {
agentId: string
name: string | null
email: string | null
open: number
paused: number
resolved: number
slaRate: number | null
avgResolutionMinutes: number | null
avgFirstResponseMinutes: number | null
totalSla: number
compliantSla: number
}
function average(values: number[]): number | null {
if (!values || values.length === 0) return null
const sum = values.reduce((acc, value) => acc + value, 0)
return sum / values.length
}
function isTicketCompliant(ticket: Doc<"tickets">, now: number) {
const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null
if (dueAt) {
if (resolvedAt) {
return resolvedAt <= dueAt
}
return dueAt >= now
}
return resolvedAt !== null
}
function ensureAgentStats(map: Map<string, AgentStatsRaw>, ticket: Doc<"tickets">): AgentStatsRaw | null {
const assigneeId = ticket.assigneeId
if (!assigneeId) return null
const key = String(assigneeId)
let stats = map.get(key)
const snapshot = ticket.assigneeSnapshot as { name?: string | null; email?: string | null } | undefined
const snapshotName = snapshot?.name ?? null
const snapshotEmail = snapshot?.email ?? null
if (!stats) {
stats = {
agentId: assigneeId,
name: snapshotName,
email: snapshotEmail,
open: 0,
paused: 0,
resolved: 0,
totalSla: 0,
compliantSla: 0,
resolutionMinutes: [],
firstResponseMinutes: [],
}
map.set(key, stats)
} else {
if (!stats.name && snapshotName) stats.name = snapshotName
if (!stats.email && snapshotEmail) stats.email = snapshotEmail
}
return stats
}
async function computeAgentStats(
ctx: QueryCtx,
tenantId: string,
viewer: Viewer,
rangeDays: number,
agentFilter?: Id<"users">,
) {
const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer)
const end = new Date()
end.setUTCHours(0, 0, 0, 0)
const endMs = end.getTime() + ONE_DAY_MS
const startMs = endMs - rangeDays * ONE_DAY_MS
const statsMap = new Map<string, AgentStatsRaw>()
const matchesFilter = (ticket: Doc<"tickets">) => {
if (!ticket.assigneeId) return false
if (agentFilter && ticket.assigneeId !== agentFilter) return false
return true
}
for (const ticket of scopedTickets) {
if (!matchesFilter(ticket)) continue
const stats = ensureAgentStats(statsMap, ticket)
if (!stats) continue
const status = normalizeStatus(ticket.status)
if (status === "PAUSED") {
stats.paused += 1
} else if (OPEN_STATUSES.has(status)) {
stats.open += 1
}
}
const inRange = scopedTickets.filter(
(ticket) => matchesFilter(ticket) && ticket.createdAt >= startMs && ticket.createdAt < endMs,
)
const now = Date.now()
for (const ticket of inRange) {
const stats = ensureAgentStats(statsMap, ticket)
if (!stats) continue
stats.totalSla += 1
if (isTicketCompliant(ticket, now)) {
stats.compliantSla += 1
}
const status = normalizeStatus(ticket.status)
if (
status === "RESOLVED" &&
typeof ticket.resolvedAt === "number" &&
ticket.resolvedAt >= startMs &&
ticket.resolvedAt < endMs
) {
stats.resolved += 1
stats.resolutionMinutes.push((ticket.resolvedAt - ticket.createdAt) / 60000)
}
if (
typeof ticket.firstResponseAt === "number" &&
ticket.firstResponseAt >= startMs &&
ticket.firstResponseAt < endMs
) {
stats.firstResponseMinutes.push((ticket.firstResponseAt - ticket.createdAt) / 60000)
}
}
const agentIds = Array.from(statsMap.keys()) as string[]
if (agentIds.length > 0) {
const docs = await Promise.all(agentIds.map((id) => ctx.db.get(id as Id<"users">)))
docs.forEach((doc, index) => {
const stats = statsMap.get(agentIds[index])
if (!stats || !doc) return
if (!stats.name && doc.name) stats.name = doc.name
if (!stats.email && doc.email) stats.email = doc.email
})
}
const computed = new Map<string, AgentStatsComputed>()
for (const [key, raw] of statsMap.entries()) {
const avgResolution = average(raw.resolutionMinutes)
const avgFirstResponse = average(raw.firstResponseMinutes)
const slaRate =
raw.totalSla > 0 ? Math.min(1, Math.max(0, raw.compliantSla / raw.totalSla)) : null
computed.set(key, {
agentId: key,
name: raw.name ?? raw.email ?? null,
email: raw.email ?? null,
open: raw.open,
paused: raw.paused,
resolved: raw.resolved,
slaRate,
avgResolutionMinutes: avgResolution,
avgFirstResponseMinutes: avgFirstResponse,
totalSla: raw.totalSla,
compliantSla: raw.compliantSla,
})
}
return computed
}
const metricResolvers: Record<string, MetricResolver> = {
"tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => {
const rangeDays = parseRange(params)
@ -419,6 +591,151 @@ const metricResolvers: Record<string, MetricResolver> = {
data: summary,
}
},
"agents.self_ticket_status": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
const data = [
{ status: "Abertos", total: stats?.open ?? 0 },
{ status: "Pausados", total: stats?.paused ?? 0 },
{ status: "Resolvidos", total: stats?.resolved ?? 0 },
]
return {
meta: { kind: "collection", key: "agents.self_ticket_status", rangeDays },
data,
}
},
"agents.self_open_total": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_open_total", unit: "tickets", rangeDays },
data: { value: stats?.open ?? 0 },
}
},
"agents.self_paused_total": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_paused_total", unit: "tickets", rangeDays },
data: { value: stats?.paused ?? 0 },
}
},
"agents.self_resolved_total": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_resolved_total", unit: "tickets", rangeDays },
data: { value: stats?.resolved ?? 0 },
}
},
"agents.self_sla_rate": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_sla_rate", rangeDays },
data: {
value: stats?.slaRate ?? 0,
total: stats?.totalSla ?? 0,
compliant: stats?.compliantSla ?? 0,
},
}
},
"agents.self_avg_resolution_minutes": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
const raw = stats?.avgResolutionMinutes ?? null
const value = raw !== null ? Math.round(raw * 10) / 10 : 0
return {
meta: { kind: "single", key: "agents.self_avg_resolution_minutes", unit: "minutes", rangeDays },
data: { value },
}
},
"agents.team_overview": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
open: stats.open,
paused: stats.paused,
resolved: stats.resolved,
slaRate: stats.slaRate !== null ? Math.round(stats.slaRate * 1000) / 10 : null,
avgResolutionMinutes:
stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : null,
}))
.sort((a, b) => b.resolved - a.resolved)
return {
meta: { kind: "collection", key: "agents.team_overview", rangeDays },
data,
}
},
"agents.team_resolved_total": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
resolved: stats.resolved,
}))
.sort((a, b) => b.resolved - a.resolved)
return {
meta: { kind: "collection", key: "agents.team_resolved_total", rangeDays },
data,
}
},
"agents.team_sla_rate": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
compliance: stats.slaRate ?? 0,
total: stats.totalSla,
compliant: stats.compliantSla,
}))
.sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0))
return {
meta: { kind: "collection", key: "agents.team_sla_rate", rangeDays },
data,
}
},
"agents.team_avg_resolution_minutes": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
minutes:
stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : 0,
}))
.sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0))
return {
meta: { kind: "collection", key: "agents.team_avg_resolution_minutes", rangeDays },
data,
}
},
}
export const run = query({