feat: add SLA category breakdown report
This commit is contained in:
parent
6ab8a6ce89
commit
a62f3d5283
8 changed files with 231 additions and 10 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
- Tickets: snapshot (`ticket.slaSnapshot`) no momento da criação inclui regra aplicada; `computeSlaDueDates` trata horas úteis (08h–18h, seg–sex) 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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 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">
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue