feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-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"
|
||||
|
|
@ -13,7 +13,7 @@ import { useState } from "react"
|
|||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
|
||||
|
|
@ -49,53 +49,84 @@ export function CsatReport() {
|
|||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">CSAT — Satisfação dos chamados</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Avalie a experiência dos usuários e acompanhe o desempenho da equipe de atendimento.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 md:justify-end">
|
||||
<SearchableCombobox
|
||||
value={selectedCompany}
|
||||
onValueChange={handleCompanyChange}
|
||||
options={comboboxOptions}
|
||||
className="w-56"
|
||||
placeholder="Filtrar por empresa"
|
||||
/>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={(value) => {
|
||||
if (value) setTimeRange(value)
|
||||
}}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
|
||||
Exportar XLSX
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
|
||||
Exportar XLSX
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardAction>
|
||||
<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(data.averageScore)}
|
||||
{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">
|
||||
|
|
@ -105,30 +136,104 @@ export function CsatReport() {
|
|||
</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">
|
||||
<IconMessageCircle2 className="size-4 text-sky-500" /> Últimas avaliações
|
||||
<IconTarget className="size-4 text-amber-500" /> Avaliações positivas
|
||||
</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Até 10 registros mais recentes.</CardDescription>
|
||||
<CardDescription className="text-neutral-600">Notas iguais ou superiores a 4.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.recent.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
|
||||
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<span>#{item.reference}</span>
|
||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||
Nota {item.score}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
<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>
|
||||
|
|
@ -140,17 +245,80 @@ export function CsatReport() {
|
|||
{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={{}} className="aspect-auto h-[260px] w-full">
|
||||
<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="Respostas" />} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue