feat: refresh dashboards experience
This commit is contained in:
parent
1900f65e5e
commit
d7d6b748cc
9 changed files with 1626 additions and 281 deletions
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue