443 lines
20 KiB
TypeScript
443 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo } from "react"
|
|
import { useQuery } from "convex/react"
|
|
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { useState } from "react"
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
|
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
|
import { cn, formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
|
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
|
|
|
const agentProductivityChartConfig = {
|
|
resolved: {
|
|
label: "Chamados resolvidos",
|
|
},
|
|
}
|
|
|
|
const priorityLabelMap: Record<string, string> = {
|
|
LOW: "Baixa",
|
|
MEDIUM: "Média",
|
|
HIGH: "Alta",
|
|
URGENT: "Urgente",
|
|
}
|
|
|
|
type CategoryBreakdownEntry = {
|
|
categoryId: string | null
|
|
categoryName: string
|
|
priority: string
|
|
total: number
|
|
responseMet: number
|
|
solutionMet: number
|
|
responseRate: number | null
|
|
solutionRate: number | null
|
|
}
|
|
|
|
function formatMinutes(value: number | null) {
|
|
if (value === null) return "—"
|
|
if (value < 60) return `${value.toFixed(0)} min`
|
|
const hours = Math.floor(value / 60)
|
|
const minutes = Math.round(value % 60)
|
|
if (minutes === 0) return `${hours}h`
|
|
return `${hours}h ${minutes}min`
|
|
}
|
|
|
|
export function SlaReport() {
|
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
|
const [timeRange, setTimeRange] = useState<string>("90d")
|
|
const { session, convexUserId, isStaff } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
const enabled = Boolean(isStaff && convexUserId)
|
|
const data = useQuery(
|
|
api.reports.slaOverview,
|
|
enabled
|
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
|
: "skip"
|
|
)
|
|
const agents = useQuery(
|
|
api.reports.agentProductivity,
|
|
enabled
|
|
? ({ 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 openedResolved = useQuery(
|
|
api.reports.openedResolvedByDay,
|
|
enabled
|
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
|
: "skip"
|
|
) as { rangeDays: number; series: Array<{ date: string; opened: number; resolved: number }> } | undefined
|
|
|
|
const channelsSeries = useQuery(
|
|
api.reports.ticketsByChannel,
|
|
enabled
|
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
|
: "skip"
|
|
) as { rangeDays: number; channels: string[]; points: Array<{ date: string; values: Record<string, number> }> } | undefined
|
|
const companies = useQuery(
|
|
api.companies.list,
|
|
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
|
|
|
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
|
if (!companies || companies.length === 0) {
|
|
return base
|
|
}
|
|
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
|
return [
|
|
base[0],
|
|
...sorted.map((company) => ({
|
|
value: company.id,
|
|
label: company.name,
|
|
})),
|
|
]
|
|
}, [companies])
|
|
|
|
const queueTotal = useMemo(
|
|
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
|
|
[data]
|
|
)
|
|
const categoryBreakdown = (data?.categoryBreakdown ?? []) as CategoryBreakdownEntry[]
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<Skeleton key={index} className="h-32 rounded-2xl" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tickets abertos</CardTitle>
|
|
<CardDescription className="text-neutral-600">Chamados ativos acompanhados pelo SLA.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totals.open}</CardContent>
|
|
</Card>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
<IconAlertTriangle className="size-4 text-amber-500" /> Vencidos
|
|
</CardTitle>
|
|
<CardDescription className="text-neutral-600">Tickets que ultrapassaram o prazo previsto.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totals.overdue}</CardContent>
|
|
</Card>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
<IconClockHour4 className="size-4 text-neutral-500" /> Tempo resposta médio
|
|
</CardTitle>
|
|
<CardDescription className="text-neutral-600">Com base nos tickets respondidos.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold text-neutral-900">
|
|
{formatMinutes(data.response.averageFirstResponseMinutes ?? null)}
|
|
<p className="mt-1 text-xs text-neutral-500">{data.response.responsesRegistered} registros</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
<IconGraph className="size-4 text-neutral-500" /> Tempo resolução médio
|
|
</CardTitle>
|
|
<CardDescription className="text-neutral-600">Chamados finalizados no período analisado.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="text-2xl font-semibold text-neutral-900">
|
|
{formatMinutes(data.resolution.averageResolutionMinutes ?? null)}
|
|
<p className="mt-1 text-xs text-neutral-500">{data.resolution.resolvedCount} resolvidos</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<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">Fila x Volume aberto</CardTitle>
|
|
<CardDescription className="text-neutral-600">
|
|
Distribuição dos {queueTotal} tickets abertos por fila de atendimento.
|
|
</CardDescription>
|
|
</div>
|
|
<CardAction>
|
|
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
|
<SearchableCombobox
|
|
value={companyId}
|
|
onValueChange={(next) => setCompanyId(next ?? "all")}
|
|
options={companyOptions}
|
|
placeholder="Todas as empresas"
|
|
className="w-full min-w-56 sm:w-56"
|
|
/>
|
|
|
|
<ToggleGroup
|
|
type="single"
|
|
value={timeRange}
|
|
onValueChange={setTimeRange}
|
|
variant="outline"
|
|
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
|
|
>
|
|
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
|
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
|
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
|
|
<Button asChild size="sm" variant="outline">
|
|
<a href={`/api/reports/sla.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
|
|
Exportar XLSX
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</CardAction>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{data.queueBreakdown.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
|
Nenhuma fila com tickets ativos no momento.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{data.queueBreakdown.map((queue: { id: string; name: string; open: number }) => (
|
|
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
|
<span className="text-xs text-neutral-500">{((queue.open / Math.max(queueTotal, 1)) * 100).toFixed(0)}% do volume aberto</span>
|
|
</div>
|
|
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
|
{queue.open} tickets
|
|
</Badge>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</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">SLA por categoria & prioridade</CardTitle>
|
|
<CardDescription className="text-neutral-600">
|
|
Taxa de cumprimento de resposta/solução considerando as regras configuradas em Categorias → SLA.
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{categoryBreakdown.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
|
Ainda não há tickets categorizados ou com SLA aplicado para este período.
|
|
</p>
|
|
) : (
|
|
<div className="overflow-hidden rounded-2xl border border-slate-200">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-neutral-500">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left">Categoria</th>
|
|
<th className="px-4 py-3 text-left">Prioridade</th>
|
|
<th className="px-4 py-3 text-right">Tickets</th>
|
|
<th className="px-4 py-3 text-right">SLA resposta</th>
|
|
<th className="px-4 py-3 text-right">SLA solução</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{categoryBreakdown.slice(0, 8).map((row) => (
|
|
<tr key={`${row.categoryId ?? "none"}-${row.priority}`} className="border-t border-slate-100">
|
|
<td className="px-4 py-3 font-medium text-neutral-900">{row.categoryName}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{priorityLabelMap[row.priority as keyof typeof priorityLabelMap] ?? row.priority}</td>
|
|
<td className="px-4 py-3 text-right font-semibold text-neutral-900">{row.total}</td>
|
|
<td className="px-4 py-3">
|
|
<RateBadge value={row.responseRate} label="Resposta" colorClass="bg-emerald-500" />
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<RateBadge value={row.solutionRate} label="Solução" colorClass="bg-sky-500" />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{categoryBreakdown.length > 8 ? (
|
|
<div className="border-t border-slate-100 bg-slate-50 px-4 py-2 text-xs text-neutral-500">
|
|
Mostrando 8 de {categoryBreakdown.length} combinações. Refine o período ou exporte o XLSX para visão completa.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</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">Abertos x Resolvidos</CardTitle>
|
|
<CardDescription className="text-neutral-600">Comparativo diário no período selecionado.</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!openedResolved || openedResolved.series.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
|
) : (
|
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
|
<AreaChart data={openedResolved.series}>
|
|
<defs>
|
|
<linearGradient id="fillOpened" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0.1} />
|
|
</linearGradient>
|
|
<linearGradient id="fillResolved" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0.1} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={24}
|
|
tickFormatter={(v) => formatDateDM(new Date(v))}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[180px]"
|
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
|
/>
|
|
}
|
|
/>
|
|
<Area dataKey="opened" type="natural" fill="url(#fillOpened)" stroke="var(--chart-1)" name="Abertos" />
|
|
<Area dataKey="resolved" type="natural" fill="url(#fillResolved)" stroke="var(--chart-2)" name="Resolvidos" />
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
)}
|
|
</CardContent>
|
|
</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">Volume por canal</CardTitle>
|
|
<CardDescription className="text-neutral-600">Distribuição diária por canal (empilhado).</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!channelsSeries || channelsSeries.points.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
|
) : (
|
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
|
<AreaChart data={channelsSeries.points.map((p) => ({ date: p.date, ...p.values }))}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={24}
|
|
tickFormatter={(v) => formatDateDM(new Date(v))}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[220px]"
|
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
|
/>
|
|
}
|
|
/>
|
|
{channelsSeries.channels.map((ch, idx) => (
|
|
<Area key={ch} dataKey={ch} type="natural" stackId="a" stroke={`var(--chart-${(idx % 5) + 1})`} fill={`var(--chart-${(idx % 5) + 1})`} />
|
|
))}
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
)}
|
|
</CardContent>
|
|
</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={agentProductivityChartConfig} 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={{ top: 8, left: 20, right: 20, bottom: 56 }}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={24} interval={0} angle={-30} height={80} />
|
|
<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">{formatHoursCompact(a.workedHours)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RateBadge({ value, label, colorClass }: { value: number | null; label: string; colorClass: string }) {
|
|
const percent = value === null ? null : Math.round(value * 100)
|
|
return (
|
|
<div className="flex flex-col gap-1 text-right">
|
|
<div className="flex items-center justify-end gap-2 text-xs text-neutral-600">
|
|
<span>{label}</span>
|
|
<span className="font-semibold text-neutral-900">{percent === null ? "—" : `${percent}%`}</span>
|
|
</div>
|
|
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
|
<div
|
|
className={cn("h-full rounded-full", colorClass)}
|
|
style={{ width: percent === null ? "0%" : `${Math.min(100, Math.max(0, percent))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|