feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -188,7 +188,7 @@ export function CompanyReport() {
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Máquinas monitoradas</CardTitle>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Dispositivos monitoradas</CardTitle>
<CardDescription className="text-neutral-600">Inventário registrado nesta empresa.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{report.machines.total}</CardContent>
@ -291,7 +291,7 @@ export function CompanyReport() {
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Sistemas operacionais</CardTitle>
<CardDescription className="text-neutral-600">Inventário das máquinas desta empresa.</CardDescription>
<CardDescription className="text-neutral-600">Inventário das dispositivos desta empresa.</CardDescription>
</CardHeader>
<CardContent className="pb-6">
<ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]">

View file

@ -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 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 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>
)
}