sistema-de-chamados/src/components/reports/sla-report.tsx
2025-11-08 02:47:39 -03:00

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 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>
)
}