Ajusta placeholders, formulários e widgets

This commit is contained in:
Esdras Renan 2025-11-06 23:13:41 -03:00
parent 343f0c8c64
commit b94cea2f9a
33 changed files with 2122 additions and 462 deletions

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