sistema-de-chamados/src/components/reports/csat-report.tsx
2025-11-14 01:25:28 -03:00

335 lines
15 KiB
TypeScript

"use client"
import { useQuery } from "convex/react"
import { IconMoodSmile, IconStars, IconMessageCircle2, IconTarget } 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, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { useMemo, useState } from "react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer"
function formatScore(value: number | null) {
if (value === null) return "—"
return value.toFixed(2)
}
export function CsatReport() {
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [timeRange, setTimeRange] = useState<string>("90d")
const [schedulerOpen, setSchedulerOpen] = useState(false)
const [dateFrom, setDateFrom] = useState<string | null>(null)
const [dateTo, setDateTo] = useState<string | null>(null)
const { session, convexUserId, isStaff, isAdmin } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const enabled = Boolean(isStaff && convexUserId)
const dateRangeFilters = useMemo(() => {
const filters: { dateFrom?: string; dateTo?: string } = {}
if (dateFrom) filters.dateFrom = dateFrom
if (dateTo) filters.dateTo = dateTo
return filters
}, [dateFrom, dateTo])
const data = useQuery(
api.reports.csatOverview,
enabled
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
range: timeRange,
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
...dateRangeFilters,
})
: "skip"
)
const companies = useQuery(
api.companies.list,
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
const companyOptions = (companies ?? []).map<SearchableComboboxOption>((company) => ({
value: company.id,
label: company.name,
}))
const comboboxOptions: SearchableComboboxOption[] = [
{ value: "all", label: "Todas as empresas" },
...companyOptions,
]
const handleCompanyChange = (value: string | null) => {
setCompanyId(value ?? "all")
}
const selectedCompany = companyId === "all" ? "all" : companyId
const averageScore = typeof data.averageScore === "number" ? data.averageScore : null
const positiveRate = typeof data.positiveRate === "number" ? data.positiveRate : null
const agentStats = Array.isArray(data.byAgent) ? data.byAgent : []
const topAgent = agentStats[0] ?? null
const agentChartData = agentStats.map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => ({
agent: agent.agentName ?? "Sem responsável",
average: agent.averageScore ?? 0,
total: agent.totalResponses ?? 0,
positive: agent.positiveRate ? Math.round(agent.positiveRate * 1000) / 10 : 0,
}))
return (
<div className="space-y-8">
{isAdmin ? (
<ReportScheduleDrawer
open={schedulerOpen}
onOpenChange={setSchedulerOpen}
defaultReports={["csat"]}
defaultRange={timeRange}
defaultCompanyId={companyId === "all" ? null : companyId}
companyOptions={comboboxOptions}
/>
) : null}
<ReportsFilterToolbar
companyId={selectedCompany}
onCompanyChange={(value) => handleCompanyChange(value)}
companyOptions={comboboxOptions}
timeRange={timeRange as "90d" | "30d" | "7d"}
onTimeRangeChange={(value) => {
setTimeRange(value)
setDateFrom(null)
setDateTo(null)
}}
dateFrom={dateFrom}
dateTo={dateTo}
onDateRangeChange={({ from, to }) => {
setDateFrom(from)
setDateTo(to)
}}
exportHref={`/api/reports/csat.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{formatScore(averageScore)} <span className="text-base font-normal text-neutral-500">/ 5</span>
</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">
<IconStars className="size-4 text-violet-500" /> Total de respostas
</CardTitle>
<CardDescription className="text-neutral-600">Avaliações coletadas nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</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">
<IconTarget className="size-4 text-amber-500" /> Avaliações positivas
</CardTitle>
<CardDescription className="text-neutral-600">Notas iguais ou superiores a 4.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{positiveRate === null ? "—" : `${(positiveRate * 100).toFixed(1).replace(".0", "")}%`}
</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">
<IconMessageCircle2 className="size-4 text-sky-500" /> Destaque do período
</CardTitle>
<CardDescription className="text-neutral-600">Agente com melhor média no recorte selecionado.</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{topAgent ? (
<>
<p className="text-base font-semibold text-neutral-900">{topAgent.agentName}</p>
<p className="text-sm text-neutral-600">
{topAgent.averageScore ? `${topAgent.averageScore.toFixed(2)} / 5` : "Sem notas suficientes"}
</p>
<p className="text-xs text-neutral-500">{topAgent.totalResponses} avaliação{topAgent.totalResponses === 1 ? "" : "s"}</p>
</>
) : (
<p className="text-sm text-neutral-500">Ainda não avaliações suficientes.</p>
)}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Últimas avaliações</CardTitle>
<CardDescription className="text-neutral-600">
Até 10 registros mais recentes enviados pelos usuários.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Ainda não coletamos nenhuma avaliação no período selecionado.
</p>
) : (
data.recent.map(
(item: {
ticketId: string
reference: number
score: number
maxScore?: number | null
comment?: string | null
receivedAt: number
assigneeName?: string | null
}) => {
const normalized =
item.maxScore && item.maxScore > 0
? Math.round(((item.score / item.maxScore) * 5) * 10) / 10
: item.score
const badgeLabel =
item.maxScore && item.maxScore !== 5
? `${item.score}/${item.maxScore}`
: normalized.toFixed(1).replace(/\.0$/, "")
return (
<div
key={`${item.ticketId}-${item.receivedAt}`}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2 text-sm"
>
<div className="flex flex-col gap-1">
<span className="font-semibold text-neutral-800">#{item.reference}</span>
{item.assigneeName ? (
<Badge variant="outline" className="w-fit rounded-full border-neutral-200 text-xs text-neutral-600">
{item.assigneeName}
</Badge>
) : null}
{item.comment ? (
<span className="text-xs text-neutral-500 line-clamp-2">{item.comment}</span>
) : null}
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {badgeLabel}
</Badge>
</div>
)
}
)
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
<CardDescription className="text-neutral-600">
Frequência de respostas para cada valor na escala de 1 a 5.
</CardDescription>
</CardHeader>
<CardContent>
{data.totalSurveys === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem respostas no período.</p>
) : (
<ChartContainer config={{ total: { label: "Respostas" } }} className="aspect-auto h-[260px] w-full">
<BarChart data={data.distribution.map((d: { score: number; total: number }) => ({ score: `Nota ${d.score}`, total: d.total }))}>
<CartesianGrid vertical={false} />
<XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="total" fill="var(--chart-3)" radius={[4, 4, 0, 0]} />
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="total" />} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Desempenho por agente</CardTitle>
<CardDescription className="text-neutral-600">
Média ponderada (1 a 5) e volume de avaliações recebidas por integrante da equipe.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[3fr_2fr]">
{agentChartData.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Ainda não avaliações atreladas a agentes no período selecionado.
</p>
) : (
<>
<ChartContainer
config={{
average: { label: "Média (1-5)", color: "hsl(var(--chart-1))" },
}}
className="aspect-auto h-[280px] w-full"
>
<BarChart data={agentChartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="agent" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="average" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
nameKey="average"
labelFormatter={(label) => `Agente: ${label}`}
valueFormatter={(value) => `${Number(value).toFixed(2)} / 5`}
/>
}
/>
</BarChart>
</ChartContainer>
<div className="space-y-3">
{agentStats.slice(0, 6).map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => (
<div
key={`${agent.agentName}-${agent.totalResponses}`}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2"
>
<div className="flex flex-col">
<span className="font-semibold text-neutral-900">{agent.agentName}</span>
<span className="text-xs text-neutral-500">
{agent.totalResponses} avaliação{agent.totalResponses === 1 ? "" : "s"}
</span>
</div>
<div className="text-right text-sm font-semibold text-neutral-900">
{agent.averageScore ? `${agent.averageScore.toFixed(2)} / 5` : "—"}
<p className="text-xs font-normal text-neutral-500">
{agent.positiveRate === null ? "—" : `${(agent.positiveRate * 100).toFixed(0)}% positivas`}
</p>
</div>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}