diff --git a/convex/reports.ts b/convex/reports.ts index 330d55a..c380d22 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -330,6 +330,89 @@ export const backlogOverview = query({ }, }); +export const agentProductivity = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + handler: async (ctx, { tenantId, viewerId, range, companyId }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId) + let tickets = await fetchScopedTickets(ctx, tenantId, viewer) + if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) + + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs) + type Acc = { + agentId: Id<"users"> + name: string | null + email: string | null + open: number + resolved: number + avgFirstResponseMinValues: number[] + avgResolutionMinValues: number[] + workedMs: number + } + const map = new Map() + + for (const t of inRange) { + const assigneeId = t.assigneeId ?? null + if (!assigneeId) continue + let acc = map.get(assigneeId) + if (!acc) { + const user = await ctx.db.get(assigneeId) + acc = { + agentId: assigneeId, + name: user?.name ?? null, + email: user?.email ?? null, + open: 0, + resolved: 0, + avgFirstResponseMinValues: [], + avgResolutionMinValues: [], + workedMs: 0, + } + map.set(assigneeId, acc) + } + const status = normalizeStatus(t.status) + if (OPEN_STATUSES.has(status)) acc.open += 1 + if (status === 'RESOLVED') acc.resolved += 1 + if (t.firstResponseAt) acc.avgFirstResponseMinValues.push((t.firstResponseAt - t.createdAt) / 60000) + if (t.resolvedAt) acc.avgResolutionMinValues.push((t.resolvedAt - t.createdAt) / 60000) + } + + // Sum work sessions by agent + for (const [agentId, acc] of map) { + const sessions = await ctx.db + .query('ticketWorkSessions') + .withIndex('by_agent', (q) => q.eq('agentId', agentId as Id<'users'>)) + .collect() + let total = 0 + for (const s of sessions) { + const started = s.startedAt + const ended = s.stoppedAt ?? s.startedAt + if (ended < startMs || started >= endMs) continue + total += s.durationMs ?? Math.max(0, (s.stoppedAt ?? Date.now()) - s.startedAt) + } + acc.workedMs = total + } + + const items = Array.from(map.values()).map((acc) => ({ + agentId: acc.agentId, + name: acc.name, + email: acc.email, + open: acc.open, + resolved: acc.resolved, + avgFirstResponseMinutes: average(acc.avgFirstResponseMinValues), + avgResolutionMinutes: average(acc.avgResolutionMinValues), + workedHours: Math.round((acc.workedMs / 3600000) * 100) / 100, + })) + // sort by resolved desc + items.sort((a, b) => b.resolved - a.resolved) + return { rangeDays: days, items } + } +}) + export const dashboardOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, handler: async (ctx, { tenantId, viewerId }) => { diff --git a/convex/schema.ts b/convex/schema.ts index 31d2cc8..c300326 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -205,7 +205,8 @@ export default defineSchema({ pauseNote: v.optional(v.string()), }) .index("by_ticket", ["ticketId"]) - .index("by_ticket_agent", ["ticketId", "agentId"]), + .index("by_ticket_agent", ["ticketId", "agentId"]) + .index("by_agent", ["agentId"]), ticketCategories: defineTable({ tenantId: v.string(), diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx index d2f72d3..939c5a1 100644 --- a/src/app/admin/channels/page.tsx +++ b/src/app/admin/channels/page.tsx @@ -9,8 +9,8 @@ export default function AdminChannelsPage() { } > diff --git a/src/app/api/reports/sla.csv/route.ts b/src/app/api/reports/sla.csv/route.ts index cc8ed0d..3680850 100644 --- a/src/app/api/reports/sla.csv/route.ts +++ b/src/app/api/reports/sla.csv/route.ts @@ -67,7 +67,7 @@ export async function GET(request: Request) { }) const rows: Array> = [] - rows.push(["Relatório", "SLA e produtividade"]) + rows.push(["Relatório", "Produtividade"]) rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")]) if (companyId) rows.push(["EmpresaId", companyId]) rows.push([]) diff --git a/src/app/reports/sla/page.tsx b/src/app/reports/sla/page.tsx index 0e7677d..b74b682 100644 --- a/src/app/reports/sla/page.tsx +++ b/src/app/reports/sla/page.tsx @@ -11,8 +11,8 @@ export default async function ReportsSlaPage() { } > diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 21e0a0c..f0dcfb6 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -82,7 +82,7 @@ const navigation: NavigationGroup[] = [ title: "Relatórios", requiredRole: "staff", items: [ - { title: "SLA e produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, + { title: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, { title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" }, @@ -93,13 +93,13 @@ const navigation: NavigationGroup[] = [ requiredRole: "admin", items: [ { - title: "Convites e acessos", + title: "Acessos", url: "/admin", icon: UserPlus, requiredRole: "admin", exact: true, }, - { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, + { title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" }, { title: "Empresas", diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index de0ff71..dc3e479 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -15,6 +15,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { useState } from "react" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { usePersistentCompanyFilter } from "@/lib/use-company-filter" +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts" +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" function formatMinutes(value: number | null) { if (value === null) return "—" @@ -36,6 +38,12 @@ export function SlaReport() { ? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) }) : "skip" ) + const agents = useQuery( + api.reports.agentProductivity, + convexUserId + ? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) }) + : "skip" + ) as { rangeDays: number; items: Array<{ agentId: string; name: string | null; email: string | null; open: number; resolved: number; avgFirstResponseMinutes: number | null; avgResolutionMinutes: number | null; workedHours: number }> } | undefined const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined const queueTotal = useMemo( @@ -164,6 +172,51 @@ export function SlaReport() { )} + + + +
+
+ Produtividade por agente + + Chamados resolvidos no período por agente (top 10) e horas trabalhadas. + +
+
+
+ + {!agents || agents.items.length === 0 ? ( +

+ Nenhum dado para o período selecionado. +

+ ) : ( +
+
+

Resolvidos por agente

+ + ({ name: a.name || a.email || 'Agente', resolved: a.resolved }))} margin={{ left: 12, right: 12 }}> + + + + } /> + + +
+
+

Horas trabalhadas (estimado)

+
    + {agents.items.slice(0, 10).map((a) => ( +
  • + {a.name || a.email || 'Agente'} + {a.workedHours.toFixed(1)} h +
  • + ))} +
+
+
+ )} +
+
) } diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 0d8c6ff..08beb67 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -44,10 +44,10 @@ const SETTINGS_ACTIONS: SettingsAction[] = [ icon: Users2, }, { - title: "Canais & roteamento", - description: "Configure canais, horários de atendimento e regras automáticas de distribuição.", + title: "Filas", + description: "Configure filas, horários de atendimento e regras automáticas de distribuição.", href: "/admin/channels", - cta: "Abrir canais", + cta: "Abrir filas", requiredRole: "admin", icon: Share2, }, @@ -60,7 +60,7 @@ const SETTINGS_ACTIONS: SettingsAction[] = [ icon: Layers3, }, { - title: "Convites e acessos", + title: "Acessos", description: "Convide novos usuários, revise papéis e acompanhe quem tem acesso ao workspace.", href: "/admin", cta: "Abrir painel",