diff --git a/convex/reports.ts b/convex/reports.ts index 4eba586..69d819b 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -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> +) { + 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(["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>(); + 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, }; } diff --git a/docs/alteracoes-2025-11-08.md b/docs/alteracoes-2025-11-08.md index 819a413..1068c31 100644 --- a/docs/alteracoes-2025-11-08.md +++ b/docs/alteracoes-2025-11-08.md @@ -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. diff --git a/src/app/reports/sla/page.tsx b/src/app/reports/sla/page.tsx index b74b682..670831d 100644 --- a/src/app/reports/sla/page.tsx +++ b/src/app/reports/sla/page.tsx @@ -11,8 +11,8 @@ export default async function ReportsSlaPage() { } > diff --git a/src/app/tickets/page.tsx b/src/app/tickets/page.tsx index 9585c25..af240bb 100644 --- a/src/app/tickets/page.tsx +++ b/src/app/tickets/page.tsx @@ -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 -} +type TicketsSearchParams = Record -export default async function TicketsPage({ searchParams }: TicketsPageProps) { +export default async function TicketsPage({ searchParams }: { searchParams: Promise }) { await requireAuthenticatedSession() - const initialFilters = deriveInitialFilters(searchParams ?? {}) + const resolvedParams = await searchParams + const initialFilters = deriveInitialFilters(resolvedParams ?? {}) return } diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 11d47d0..3f46e34 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -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" }, diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index f55d64a..a08b629 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -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 = { + 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() { + + +
+
+ SLA por categoria & prioridade + + Taxa de cumprimento de resposta/solução considerando as regras configuradas em Categorias → SLA. + +
+
+
+ + {categoryBreakdown.length === 0 ? ( +

+ Ainda não há tickets categorizados ou com SLA aplicado para este período. +

+ ) : ( +
+ + + + + + + + + + + + {categoryBreakdown.slice(0, 8).map((row) => ( + + + + + + + + ))} + +
CategoriaPrioridadeTicketsSLA respostaSLA solução
{row.categoryName}{priorityLabelMap[row.priority as keyof typeof priorityLabelMap] ?? row.priority}{row.total} + + + +
+ {categoryBreakdown.length > 8 ? ( +
+ Mostrando 8 de {categoryBreakdown.length} combinações. Refine o período ou exporte o XLSX para visão completa. +
+ ) : null} +
+ )} +
+
+
@@ -350,3 +423,21 @@ export function SlaReport() {
) } + +function RateBadge({ value, label, colorClass }: { value: number | null; label: string; colorClass: string }) { + const percent = value === null ? null : Math.round(value * 100) + return ( +
+
+ {label} + {percent === null ? "—" : `${percent}%`} +
+
+
+
+
+ ) +} diff --git a/tests/reports.sla-backlog.test.ts b/tests/reports.sla-backlog.test.ts index e4716a8..7c51399 100644 --- a/tests/reports.sla-backlog.test.ts +++ b/tests/reports.sla-backlog.test.ts @@ -60,6 +60,20 @@ function buildQueue(overrides: Partial>): Doc<"queues"> { return { ...(base as Doc<"queues">), ...overrides } } +function buildCategory(overrides: Partial>): Doc<"ticketCategories"> { + const base: Record = { + _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[0] + const ctx = createReportsCtx({ tickets, queues: [queue], categories: [category] }) as Parameters[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, + }, + ]) }) }) diff --git a/tests/utils/report-test-helpers.ts b/tests/utils/report-test-helpers.ts index 64158d2..5744f96 100644 --- a/tests/utils/report-test-helpers.ts +++ b/tests/utils/report-test-helpers.ts @@ -6,6 +6,7 @@ type ReportsCtxOptions = { tickets?: Doc<"tickets">[] createdRangeTickets?: Doc<"tickets">[] queues?: Doc<"queues">[] + categories?: Doc<"ticketCategories">[] companies?: Map> users?: Map> ticketEventsByTicket?: Map> @@ -38,6 +39,7 @@ export function createReportsCtx({ tickets = [], createdRangeTickets = tickets, queues = [], + categories = [], companies = new Map>(), users = new Map>(), ticketEventsByTicket = new Map>(), @@ -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) => {