feat: add SLA category breakdown report

This commit is contained in:
Esdras Renan 2025-11-08 02:47:39 -03:00
parent 6ab8a6ce89
commit a62f3d5283
8 changed files with 231 additions and 10 deletions

View file

@ -89,7 +89,7 @@ const navigation: NavigationGroup[] = [
requiredRole: "staff",
items: [
{ title: "Painéis customizados", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" },
{ title: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
{ title: "SLA & 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: "Empresas", url: "/reports/company", icon: Building2, requiredRole: "staff" },

View file

@ -16,7 +16,7 @@ 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 { formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
import { cn, formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
const agentProductivityChartConfig = {
@ -25,6 +25,24 @@ const agentProductivityChartConfig = {
},
}
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`
@ -90,6 +108,7 @@ export function SlaReport() {
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
[data]
)
const categoryBreakdown = (data?.categoryBreakdown ?? []) as CategoryBreakdownEntry[]
if (!data) {
return (
@ -209,6 +228,60 @@ export function SlaReport() {
</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">
@ -350,3 +423,21 @@ export function SlaReport() {
</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>
)
}