Reports: add charts to Produtividade (areas + channels), CSAT (bar), Backlog (pie+bar), Horas (stacked bar); deploy Convex reports agent productivity
This commit is contained in:
parent
67df0d4308
commit
68b897c30c
4 changed files with 157 additions and 40 deletions
|
|
@ -14,6 +14,8 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
import { Pie, PieChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, string> = {
|
const PRIORITY_LABELS: Record<string, string> = {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
|
|
@ -161,18 +163,31 @@ export function BacklogReport() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
{Object.keys(data.priorityCounts).length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-[1.2fr_1fr]">
|
||||||
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||||
|
<Pie
|
||||||
|
data={(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => ({ name: PRIORITY_LABELS[priority] ?? priority, total, fill: `var(--chart-${{LOW:1,MEDIUM:2,HIGH:3,URGENT:4}[priority as keyof typeof PRIORITY_LABELS]||5})` }))}
|
||||||
|
dataKey="total"
|
||||||
|
nameKey="name"
|
||||||
|
label
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<ul className="space-y-3">
|
||||||
{(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => (
|
{(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => (
|
||||||
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
<li key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||||
<span className="text-sm font-medium text-neutral-800">
|
<span className="text-sm font-medium text-neutral-800">{PRIORITY_LABELS[priority] ?? priority}</span>
|
||||||
{PRIORITY_LABELS[priority] ?? priority}
|
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">{total} ticket{total === 1 ? "" : "s"}</Badge>
|
||||||
</span>
|
</li>
|
||||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
|
||||||
{total} ticket{total === 1 ? "" : "s"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -189,18 +204,14 @@ export function BacklogReport() {
|
||||||
Nenhuma fila com tickets abertos no momento.
|
Nenhuma fila com tickets abertos no momento.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
{data.queueCounts.map((queue: { id: string; name: string; total: number }) => (
|
<BarChart data={data.queueCounts.map((q: { name: string; total: number }) => ({ name: q.name, total: q.total }))}>
|
||||||
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
<CartesianGrid vertical={false} />
|
||||||
<div className="flex flex-col">
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
||||||
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
<Bar dataKey="total" fill="var(--chart-5)" radius={[4, 4, 0, 0]} />
|
||||||
</div>
|
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Abertos" />} />
|
||||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
</BarChart>
|
||||||
{queue.total} em aberto
|
</ChartContainer>
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { useState } from "react"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
|
@ -131,21 +133,18 @@ export function CsatReport() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-3">
|
{data.totalSurveys === 0 ? (
|
||||||
{data.distribution.map((entry: { score: number; total: number }) => (
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem respostas no período.</p>
|
||||||
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
<BarChart data={data.distribution.map((d: { score: number; total: number }) => ({ score: `Nota ${d.score}`, total: d.total }))}>
|
||||||
Nota {entry.score}
|
<CartesianGrid vertical={false} />
|
||||||
</Badge>
|
<XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
<span className="text-sm text-neutral-700">{entry.total} respostas</span>
|
<Bar dataKey="total" fill="var(--chart-3)" radius={[4, 4, 0, 0]} />
|
||||||
</div>
|
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Respostas" />} />
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
</BarChart>
|
||||||
{data.totalSurveys === 0 ? "0%" : `${((entry.total / data.totalSurveys) * 100).toFixed(0)}%`}
|
</ChartContainer>
|
||||||
</span>
|
)}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
|
||||||
type HoursItem = {
|
type HoursItem = {
|
||||||
companyId: string
|
companyId: string
|
||||||
|
|
@ -92,6 +94,34 @@ export function HoursReport() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top clientes por horas</CardTitle>
|
||||||
|
<CardDescription>Comparativo empilhado de horas internas x externas (top 10).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!filteredWithComputed || filteredWithComputed.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
|
<BarChart
|
||||||
|
data={filteredWithComputed
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((r) => ({ name: r.name, internas: r.internal, externas: r.external }))}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
||||||
|
<Bar dataKey="internas" stackId="a" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="externas" stackId="a" fill="var(--chart-2)" radius={[4, 4, 0, 0]} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Horas</CardTitle>
|
<CardTitle>Horas</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
|
||||||
function formatMinutes(value: number | null) {
|
function formatMinutes(value: number | null) {
|
||||||
|
|
@ -44,6 +44,20 @@ export function SlaReport() {
|
||||||
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
||||||
: "skip"
|
: "skip"
|
||||||
) as { rangeDays: number; items: Array<{ agentId: string; name: string | null; email: string | null; open: number; resolved: number; avgFirstResponseMinutes: number | null; avgResolutionMinutes: number | null; workedHours: number }> } | undefined
|
) as { rangeDays: number; items: Array<{ agentId: string; name: string | null; email: string | null; open: number; resolved: number; avgFirstResponseMinutes: number | null; avgResolutionMinutes: number | null; workedHours: number }> } | undefined
|
||||||
|
|
||||||
|
const openedResolved = useQuery(
|
||||||
|
api.reports.openedResolvedByDay,
|
||||||
|
convexUserId
|
||||||
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
||||||
|
: "skip"
|
||||||
|
) as { rangeDays: number; series: Array<{ date: string; opened: number; resolved: number }> } | undefined
|
||||||
|
|
||||||
|
const channelsSeries = useQuery(
|
||||||
|
api.reports.ticketsByChannel,
|
||||||
|
convexUserId
|
||||||
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
||||||
|
: "skip"
|
||||||
|
) as { rangeDays: number; channels: string[]; points: Array<{ date: string; values: Record<string, number> }> } | undefined
|
||||||
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
const queueTotal = useMemo(
|
const queueTotal = useMemo(
|
||||||
|
|
@ -173,6 +187,69 @@ export function SlaReport() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Abertos x Resolvidos</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">Comparativo diário no período selecionado.</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!openedResolved || openedResolved.series.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
|
<AreaChart data={openedResolved.series}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillOpened" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="fillResolved" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" />} />
|
||||||
|
<Area dataKey="opened" type="natural" fill="url(#fillOpened)" stroke="var(--chart-1)" name="Abertos" />
|
||||||
|
<Area dataKey="resolved" type="natural" fill="url(#fillResolved)" stroke="var(--chart-2)" name="Resolvidos" />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Volume por canal</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">Distribuição diária por canal (empilhado).</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!channelsSeries || channelsSeries.points.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
|
<AreaChart data={channelsSeries.points.map((p) => ({ date: p.date, ...p.values }))}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
|
||||||
|
{channelsSeries.channels.map((ch, idx) => (
|
||||||
|
<Area key={ch} dataKey={ch} type="natural" stackId="a" stroke={`var(--chart-${(idx % 5) + 1})`} fill={`var(--chart-${(idx % 5) + 1})`} />
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue