Rename menus: 'Acessos', 'Filas', 'Produtividade'; add agent productivity section with bar chart; adjust CSV label; update channels page title
This commit is contained in:
parent
347609a186
commit
67df0d4308
8 changed files with 150 additions and 13 deletions
|
|
@ -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<string, Acc>()
|
||||||
|
|
||||||
|
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({
|
export const dashboardOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId, viewerId }) => {
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,8 @@ export default defineSchema({
|
||||||
pauseNote: v.optional(v.string()),
|
pauseNote: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
.index("by_ticket", ["ticketId"])
|
.index("by_ticket", ["ticketId"])
|
||||||
.index("by_ticket_agent", ["ticketId", "agentId"]),
|
.index("by_ticket_agent", ["ticketId", "agentId"])
|
||||||
|
.index("by_agent", ["agentId"]),
|
||||||
|
|
||||||
ticketCategories: defineTable({
|
ticketCategories: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ export default function AdminChannelsPage() {
|
||||||
<AppShell
|
<AppShell
|
||||||
header={
|
header={
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Filas e canais"
|
title="Filas"
|
||||||
lead="Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento."
|
lead="Configure as filas internas e vincule-as aos times responsáveis pelo atendimento."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export async function GET(request: Request) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows: Array<Array<unknown>> = []
|
const rows: Array<Array<unknown>> = []
|
||||||
rows.push(["Relatório", "SLA e produtividade"])
|
rows.push(["Relatório", "Produtividade"])
|
||||||
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")])
|
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")])
|
||||||
if (companyId) rows.push(["EmpresaId", companyId])
|
if (companyId) rows.push(["EmpresaId", companyId])
|
||||||
rows.push([])
|
rows.push([])
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export default async function ReportsSlaPage() {
|
||||||
<AppShell
|
<AppShell
|
||||||
header={
|
header={
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Relatório de SLA"
|
title="Produtividade"
|
||||||
lead="Acompanhe tempos de resposta, resolução e balanço de filas em tempo real."
|
lead="SLA prático: tempos de resposta, resolução e volume por fila."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ const navigation: NavigationGroup[] = [
|
||||||
title: "Relatórios",
|
title: "Relatórios",
|
||||||
requiredRole: "staff",
|
requiredRole: "staff",
|
||||||
items: [
|
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: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
||||||
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
|
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
|
||||||
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
|
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
|
||||||
|
|
@ -93,13 +93,13 @@ const navigation: NavigationGroup[] = [
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Convites e acessos",
|
title: "Acessos",
|
||||||
url: "/admin",
|
url: "/admin",
|
||||||
icon: UserPlus,
|
icon: UserPlus,
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
exact: true,
|
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: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" },
|
||||||
{
|
{
|
||||||
title: "Empresas",
|
title: "Empresas",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
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) {
|
function formatMinutes(value: number | null) {
|
||||||
if (value === null) return "—"
|
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">) })
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
||||||
: "skip"
|
: "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 companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
const queueTotal = useMemo(
|
const queueTotal = useMemo(
|
||||||
|
|
@ -164,6 +172,51 @@ export function SlaReport() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Produtividade por agente</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Chamados resolvidos no período por agente (top 10) e horas trabalhadas.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!agents || agents.items.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||||
|
Nenhum dado para o período selecionado.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-neutral-500">Resolvidos por agente</p>
|
||||||
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
|
<BarChart data={agents.items.slice(0, 10).map((a) => ({ name: a.name || a.email || 'Agente', resolved: a.resolved }))} margin={{ left: 12, right: 12 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
||||||
|
<Bar dataKey="resolved" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="resolved" />} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-neutral-500">Horas trabalhadas (estimado)</p>
|
||||||
|
<ul className="divide-y divide-slate-200 overflow-hidden rounded-md border border-slate-200">
|
||||||
|
{agents.items.slice(0, 10).map((a) => (
|
||||||
|
<li key={a.agentId} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
|
<span className="truncate">{a.name || a.email || 'Agente'}</span>
|
||||||
|
<span className="text-neutral-700">{a.workedHours.toFixed(1)} h</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,10 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
|
||||||
icon: Users2,
|
icon: Users2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Canais & roteamento",
|
title: "Filas",
|
||||||
description: "Configure canais, horários de atendimento e regras automáticas de distribuição.",
|
description: "Configure filas, horários de atendimento e regras automáticas de distribuição.",
|
||||||
href: "/admin/channels",
|
href: "/admin/channels",
|
||||||
cta: "Abrir canais",
|
cta: "Abrir filas",
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
icon: Share2,
|
icon: Share2,
|
||||||
},
|
},
|
||||||
|
|
@ -60,7 +60,7 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
|
||||||
icon: Layers3,
|
icon: Layers3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Convites e acessos",
|
title: "Acessos",
|
||||||
description: "Convide novos usuários, revise papéis e acompanhe quem tem acesso ao workspace.",
|
description: "Convide novos usuários, revise papéis e acompanhe quem tem acesso ao workspace.",
|
||||||
href: "/admin",
|
href: "/admin",
|
||||||
cta: "Abrir painel",
|
cta: "Abrir painel",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue