335 lines
15 KiB
TypeScript
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 há 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 há 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>
|
|
)
|
|
}
|