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

@ -30,6 +30,23 @@ function average(values: number[]) {
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function resolveCategoryName(
categoryId: string | null,
snapshot: { categoryName?: string } | null,
categories: Map<string, Doc<"ticketCategories">>
) {
if (categoryId) {
const category = categories.get(categoryId)
if (category?.name) {
return category.name
}
}
if (snapshot?.categoryName && snapshot.categoryName.trim().length > 0) {
return snapshot.categoryName.trim()
}
return "Sem categoria"
}
export const OPEN_STATUSES = new Set<TicketStatusNormalized>(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]);
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
@ -95,6 +112,18 @@ export async function fetchTickets(ctx: QueryCtx, tenantId: string) {
.collect();
}
async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) {
const categories = await ctx.db
.query("ticketCategories")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const map = new Map<string, Doc<"ticketCategories">>();
for (const category of categories) {
map.set(String(category._id), category);
}
return map;
}
export async function fetchScopedTickets(
ctx: QueryCtx,
tenantId: string,
@ -375,6 +404,7 @@ export async function slaOverviewHandler(
const startMs = endMs - days * ONE_DAY_MS;
const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs);
const queues = await fetchQueues(ctx, tenantId);
const categoriesMap = await fetchCategoryMap(ctx, tenantId);
const now = Date.now();
const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
@ -400,6 +430,53 @@ export async function slaOverviewHandler(
};
});
const categoryStats = new Map<
string,
{
categoryId: string | null
categoryName: string
priority: string
total: number
responseMet: number
solutionMet: number
}
>()
for (const ticket of inRange) {
const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string; priority?: string } | null
const rawCategoryId = ticket.categoryId ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null
const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap)
const priority = (snapshot?.priority ?? ticket.priority ?? "MEDIUM").toUpperCase()
const key = `${rawCategoryId ?? "uncategorized"}::${priority}`
let stat = categoryStats.get(key)
if (!stat) {
stat = {
categoryId: rawCategoryId,
categoryName,
priority,
total: 0,
responseMet: 0,
solutionMet: 0,
}
categoryStats.set(key, stat)
}
stat.total += 1
if (ticket.slaResponseStatus === "met") {
stat.responseMet += 1
}
if (ticket.slaSolutionStatus === "met") {
stat.solutionMet += 1
}
}
const categoryBreakdown = Array.from(categoryStats.values())
.map((entry) => ({
...entry,
responseRate: entry.total > 0 ? entry.responseMet / entry.total : null,
solutionRate: entry.total > 0 ? entry.solutionMet / entry.total : null,
}))
.sort((a, b) => b.total - a.total)
return {
totals: {
total: inRange.length,
@ -416,6 +493,7 @@ export async function slaOverviewHandler(
resolvedCount: resolutionTimes.length,
},
queueBreakdown,
categoryBreakdown,
rangeDays: days,
};
}

View file

@ -14,6 +14,7 @@
- Tickets: snapshot (`ticket.slaSnapshot`) no momento da criação inclui regra aplicada; `computeSlaDueDates` trata horas úteis (08h18h, segsex) e calendário corrido; status respeita pausas configuradas, com `slaPausedAt/slaPausedMs` e `build*CompletionPatch`.
- Front-end: `ticket-details-panel` e `ticket-summary-header` exibem badges de SLA (on_track/at_risk/breached/met) com due dates; `sla-utils.ts` centraliza cálculo para UI.
- Prisma: modelo `Ticket` agora persiste `slaSnapshot`, due dates e estado de pausa; migration `20251108042551_add_ticket_sla_fields` aplicada e client regenerado.
- **Relatório “SLA & Produtividade” com corte por categoria/prioridade**`/reports/sla` ganhou tabela dedicada mostrando para cada categoria/prioridade o volume e as taxas de cumprimento de resposta e solução (dados vêm de `categoryBreakdown` no `slaOverview`). O item correspondente na sidebar agora se chama “SLA & Produtividade” para deixar o destino mais claro.
- **Polyfill de performance**`src/lib/performance-measure-polyfill.ts` previne `performance.measure` negativo em browsers/server; importado em `app/layout.tsx`.
- **Admin auth fallback** — páginas server-side (`/admin`, `/admin/users`) tratam bancos recém-criados onde `AuthUser` ainda não existe, exibindo cards vazios em vez do crash `AuthUser table does not exist`.
- **Chips de admissão/desligamento**`convex/tickets.ts` garante `formTemplateLabel` com fallback nas labels configuradas (ex.: “Admissão de colaborador”), corrigindo etiquetas sem acentuação na listagem/título do ticket.

View file

@ -11,8 +11,8 @@ export default async function ReportsSlaPage() {
<AppShell
header={
<SiteHeader
title="Produtividade"
lead="SLA prático: tempos de resposta, resolução e volume por fila."
title="SLA & Produtividade"
lead="Visão consolidada de SLA por fila, categoria/prioridade e desempenho da equipe."
/>
}
>

View file

@ -3,13 +3,12 @@ import { requireAuthenticatedSession } from "@/lib/auth-server"
import type { TicketFiltersState } from "@/lib/ticket-filters"
import type { TicketStatus } from "@/lib/schemas/ticket"
type TicketsPageProps = {
searchParams?: Record<string, string | string[] | undefined>
}
type TicketsSearchParams = Record<string, string | string[] | undefined>
export default async function TicketsPage({ searchParams }: TicketsPageProps) {
export default async function TicketsPage({ searchParams }: { searchParams: Promise<TicketsSearchParams> }) {
await requireAuthenticatedSession()
const initialFilters = deriveInitialFilters(searchParams ?? {})
const resolvedParams = await searchParams
const initialFilters = deriveInitialFilters(resolvedParams ?? {})
return <TicketsPageClient initialFilters={initialFilters} />
}

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

View file

@ -60,6 +60,20 @@ function buildQueue(overrides: Partial<Doc<"queues">>): Doc<"queues"> {
return { ...(base as Doc<"queues">), ...overrides }
}
function buildCategory(overrides: Partial<Doc<"ticketCategories">>): Doc<"ticketCategories"> {
const base: Record<string, unknown> = {
_id: "category_base" as Id<"ticketCategories">,
tenantId: TENANT_ID,
name: "Onboarding",
slug: "onboarding",
description: null,
order: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
}
return { ...(base as Doc<"ticketCategories">), ...overrides }
}
describe("convex.reports.slaOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0)
@ -83,26 +97,38 @@ describe("convex.reports.slaOverview", () => {
_id: "queue_1" as Id<"queues">,
name: "Suporte Nível 1",
})
const category = buildCategory({
_id: "category_1" as Id<"ticketCategories">,
name: "Admissões",
})
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
status: "PENDING",
queueId: queue._id,
categoryId: category._id,
createdAt: Date.UTC(2024, 4, 7, 9, 0, 0),
dueAt: Date.UTC(2024, 4, 7, 11, 0, 0),
slaSnapshot: { categoryId: category._id, categoryName: category.name, priority: "MEDIUM" },
slaResponseStatus: "pending",
slaSolutionStatus: "pending",
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
queueId: queue._id,
categoryId: category._id,
createdAt: Date.UTC(2024, 4, 6, 8, 0, 0),
firstResponseAt: Date.UTC(2024, 4, 6, 8, 30, 0),
resolvedAt: Date.UTC(2024, 4, 6, 10, 0, 0),
slaSnapshot: { categoryId: category._id, categoryName: category.name, priority: "MEDIUM" },
slaResponseStatus: "met",
slaSolutionStatus: "met",
}),
]
const ctx = createReportsCtx({ tickets, queues: [queue] }) as Parameters<typeof slaOverviewHandler>[0]
const ctx = createReportsCtx({ tickets, queues: [queue], categories: [category] }) as Parameters<typeof slaOverviewHandler>[0]
const result = await slaOverviewHandler(ctx, {
tenantId: TENANT_ID,
@ -115,6 +141,18 @@ describe("convex.reports.slaOverview", () => {
expect(result.response).toEqual({ averageFirstResponseMinutes: 30, responsesRegistered: 1 })
expect(result.resolution).toEqual({ averageResolutionMinutes: 120, resolvedCount: 1 })
expect(result.queueBreakdown).toEqual([{ id: queue._id, name: queue.name, open: 1 }])
expect(result.categoryBreakdown).toEqual([
{
categoryId: String(category._id),
categoryName: category.name,
priority: "MEDIUM",
total: 2,
responseMet: 1,
solutionMet: 1,
responseRate: 0.5,
solutionRate: 0.5,
},
])
})
})

View file

@ -6,6 +6,7 @@ type ReportsCtxOptions = {
tickets?: Doc<"tickets">[]
createdRangeTickets?: Doc<"tickets">[]
queues?: Doc<"queues">[]
categories?: Doc<"ticketCategories">[]
companies?: Map<string, Doc<"companies">>
users?: Map<string, Doc<"users">>
ticketEventsByTicket?: Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>
@ -38,6 +39,7 @@ export function createReportsCtx({
tickets = [],
createdRangeTickets = tickets,
queues = [],
categories = [],
companies = new Map<string, Doc<"companies">>(),
users = new Map<string, Doc<"users">>(),
ticketEventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>(),
@ -78,6 +80,18 @@ export function createReportsCtx({
}
}
if (table === "ticketCategories") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
cb?.(noopIndexBuilder)
return {
collect: vi.fn(async () => categories),
}
}),
collect: vi.fn(async () => categories),
}
}
if (table === "ticketEvents") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {