Ajusta placeholders, formulários e widgets
This commit is contained in:
parent
343f0c8c64
commit
b94cea2f9a
33 changed files with 2122 additions and 462 deletions
300
src/components/reports/category-report.tsx
Normal file
300
src/components/reports/category-report.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
import { Layers3, PieChart as PieChartIcon, Award } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type CategoryInsightsResponse = {
|
||||
rangeDays: number
|
||||
totalTickets: number
|
||||
categories: Array<{
|
||||
id: string | null
|
||||
name: string
|
||||
total: number
|
||||
resolved: number
|
||||
topAgent: { id: string | null; name: string | null; total: number } | null
|
||||
agents: Array<{ id: string | null; name: string | null; total: number }>
|
||||
}>
|
||||
spotlight: {
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
agentId: string | null
|
||||
agentName: string | null
|
||||
tickets: number
|
||||
} | null
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("pt-BR")
|
||||
const percentFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 })
|
||||
|
||||
const chartConfig = {
|
||||
tickets: {
|
||||
label: "Tickets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} as const
|
||||
|
||||
export function CategoryReport() {
|
||||
const { session, convexUserId, isStaff } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const [timeRange, setTimeRange] = useState("90d")
|
||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||
const enabled = Boolean(isStaff && convexUserId)
|
||||
|
||||
const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined
|
||||
|
||||
const data = useQuery(
|
||||
api.reports.categoryInsights,
|
||||
enabled
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
range: timeRange,
|
||||
companyId: companyFilter,
|
||||
}
|
||||
: "skip",
|
||||
) as CategoryInsightsResponse | undefined
|
||||
|
||||
const companies = useQuery(
|
||||
api.companies.list,
|
||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip",
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) return base
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
const categories = data?.categories ?? []
|
||||
const leadingCategory = categories[0] ?? null
|
||||
const spotlight = data?.spotlight ?? null
|
||||
|
||||
const chartData = categories.length
|
||||
? categories.slice(0, 8).map((category) => ({
|
||||
name: category.name,
|
||||
tickets: category.total,
|
||||
topAgent: category.topAgent?.name ?? "—",
|
||||
agentTickets: category.topAgent?.total ?? 0,
|
||||
}))
|
||||
: []
|
||||
|
||||
const tableData = categories.slice(0, 10)
|
||||
const totalTickets = data?.totalTickets ?? 0
|
||||
|
||||
const chartHeight = Math.max(240, chartData.length * 48)
|
||||
|
||||
const summarySkeleton = (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="border-slate-200">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{!data ? (
|
||||
summarySkeleton
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<Layers3 className="size-4" /> Tickets analisados
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-neutral-900">{numberFormatter.format(totalTickets)}</p>
|
||||
<p className="text-xs text-neutral-500">Últimos {data.rangeDays} dias</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<PieChartIcon className="size-4" /> Categoria líder
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-neutral-900">{leadingCategory ? leadingCategory.name : "—"}</p>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{leadingCategory
|
||||
? `${numberFormatter.format(leadingCategory.total)} chamados (${percentFormatter.format(
|
||||
totalTickets > 0 ? (leadingCategory.total / totalTickets) * 100 : 0,
|
||||
)}%)`
|
||||
: "Sem registros no período."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<Award className="size-4" /> Agente destaque
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-neutral-900">{spotlight?.agentName ?? "—"}</p>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{spotlight
|
||||
? `${spotlight.tickets} chamados em ${spotlight.categoryName}`
|
||||
: "Nenhum agente se destacou neste período."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Categorias mais atendidas</CardTitle>
|
||||
<CardDescription className="text-neutral-600">
|
||||
Compare o volume de solicitações por categoria e identifique quais agentes concentram o atendimento de cada tema.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 md:w-64"
|
||||
/>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={(value) => value && setTimeRange(value)}
|
||||
variant="outline"
|
||||
className="hidden md:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{!data ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[260px] w-full rounded-2xl" />
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
</div>
|
||||
) : data.categories.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-8 text-center text-sm text-neutral-500">
|
||||
Nenhum ticket encontrado para o período selecionado.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full" style={{ minHeight: chartHeight }}>
|
||||
<BarChart data={chartData} layout="vertical" margin={{ right: 16, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" hide domain={[0, "dataMax"]} />
|
||||
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={160} />
|
||||
<ChartTooltip
|
||||
cursor={{ fill: "hsl(var(--muted))" }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name, item) => (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{item?.payload?.topAgent ?? "Sem responsável"}</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{numberFormatter.format(Number(value))} tickets
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="tickets" fill="var(--color-tickets)" radius={[0, 6, 6, 0]} barSize={20} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-neutral-900">Agentes por categoria</h4>
|
||||
<div className="space-y-3">
|
||||
{tableData.slice(0, 4).map((category) => {
|
||||
const share = totalTickets > 0 ? (category.total / totalTickets) * 100 : 0
|
||||
const agent = category.topAgent
|
||||
return (
|
||||
<div key={`${category.id ?? "uncategorized"}-card`} className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-neutral-900">{category.name}</p>
|
||||
<p className="text-xs text-neutral-500">{numberFormatter.format(category.total)} tickets · {percentFormatter.format(share)}%</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs font-semibold">
|
||||
{agent?.name ?? "Sem responsável"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
{agent ? `${numberFormatter.format(agent.total)} chamados atribuídos a ${agent.name ?? "—"}` : "Sem agentes atribuídos."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Categoria</TableHead>
|
||||
<TableHead className="text-right">Tickets</TableHead>
|
||||
<TableHead className="text-right">Participação</TableHead>
|
||||
<TableHead className="text-right">Agente destaque</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableData.map((category) => {
|
||||
const share = totalTickets > 0 ? (category.total / totalTickets) * 100 : 0
|
||||
return (
|
||||
<TableRow key={category.id ?? `sem-categoria-${category.name}`}>
|
||||
<TableCell className="font-medium text-neutral-900">{category.name}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">{numberFormatter.format(category.total)}</TableCell>
|
||||
<TableCell className="text-right text-sm text-neutral-600">{percentFormatter.format(share)}%</TableCell>
|
||||
<TableCell className="text-right text-sm text-neutral-700">
|
||||
{category.topAgent ? (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-neutral-900">{category.topAgent.name ?? "—"}</span>
|
||||
<span className="text-xs text-neutral-500">{category.topAgent.total} chamados</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-neutral-400">Sem responsável</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue