275 lines
9.8 KiB
TypeScript
275 lines
9.8 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
|
|
|
import { useQuery } from "convex/react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|
import { useIsMobile } from "@/hooks/use-mobile"
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
ChartConfig,
|
|
ChartContainer,
|
|
ChartTooltip,
|
|
ChartTooltipContent,
|
|
} from "@/components/ui/chart"
|
|
import { formatDateDM, formatDateDMY } from "@/lib/utils"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Input } from "@/components/ui/input"
|
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
|
import {
|
|
ToggleGroup,
|
|
ToggleGroupItem,
|
|
} from "@/components/ui/toggle-group"
|
|
|
|
export const description = "Distribuição semanal de tickets por canal"
|
|
|
|
export function ChartAreaInteractive() {
|
|
const [mounted, setMounted] = React.useState(false)
|
|
const isMobile = useIsMobile()
|
|
const [timeRange, setTimeRange] = React.useState("7d")
|
|
// Persistir seleção de empresa globalmente
|
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
|
const [companyQuery, setCompanyQuery] = React.useState("")
|
|
const { session, convexUserId, isStaff } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
|
|
React.useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (isMobile) {
|
|
setTimeRange("7d")
|
|
}
|
|
}, [isMobile])
|
|
|
|
const reportsEnabled = Boolean(isStaff && convexUserId)
|
|
const report = useQuery(
|
|
api.reports.ticketsByChannel,
|
|
reportsEnabled
|
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
|
: "skip"
|
|
)
|
|
const companies = useQuery(api.companies.list, reportsEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
|
const filteredCompanies = React.useMemo(() => {
|
|
const q = companyQuery.trim().toLowerCase()
|
|
if (!q) return companies ?? []
|
|
return (companies ?? []).filter((c) => c.name.toLowerCase().includes(q))
|
|
}, [companies, companyQuery])
|
|
|
|
const channels = React.useMemo(() => report?.channels ?? [], [report])
|
|
|
|
const palette = React.useMemo(
|
|
() => [
|
|
"var(--chart-1)",
|
|
"var(--chart-2)",
|
|
"var(--chart-3)",
|
|
"var(--chart-4)",
|
|
"var(--chart-5)",
|
|
],
|
|
[]
|
|
)
|
|
|
|
const chartConfig = React.useMemo(() => {
|
|
const entries = channels.map((channel: string, index: number) => [
|
|
channel,
|
|
{
|
|
label: channel
|
|
.toLowerCase()
|
|
.replace(/_/g, " ")
|
|
.replace(/\b\w/g, (letter) => letter.toUpperCase()),
|
|
color: palette[index % palette.length],
|
|
},
|
|
])
|
|
return Object.fromEntries(entries) as ChartConfig
|
|
}, [channels, palette])
|
|
|
|
const chartData = React.useMemo(() => {
|
|
if (!report?.points) return []
|
|
return report.points.map((point: { date: string; values: Record<string, number> }) => {
|
|
const entry: Record<string, number | string> = { date: point.date }
|
|
for (const channel of channels) {
|
|
entry[channel] = point.values[channel] ?? 0
|
|
}
|
|
return entry
|
|
})
|
|
}, [channels, report])
|
|
|
|
if (!mounted) {
|
|
return (
|
|
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
|
Carregando gráfico...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card className="@container/card">
|
|
<CardHeader>
|
|
<CardTitle>Entrada de tickets por canal</CardTitle>
|
|
<CardDescription>
|
|
<span className="hidden @[540px]/card:block">
|
|
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
|
|
</span>
|
|
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
|
|
</CardDescription>
|
|
<CardAction>
|
|
<div className="flex w-full flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-2">
|
|
{/* Company picker with search */}
|
|
<Select value={companyId} onValueChange={(v) => { setCompanyId(v); }}>
|
|
<SelectTrigger className="w-full min-w-56 sm:w-64">
|
|
<SelectValue placeholder="Todas as empresas" />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
<div className="p-2">
|
|
<Input
|
|
placeholder="Pesquisar empresa..."
|
|
value={companyQuery}
|
|
onChange={(e) => setCompanyQuery(e.target.value)}
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<SelectItem value="all">Todas as empresas</SelectItem>
|
|
{filteredCompanies.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Desktop time range toggles */}
|
|
<ToggleGroup
|
|
type="single"
|
|
value={timeRange}
|
|
onValueChange={setTimeRange}
|
|
variant="outline"
|
|
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
|
>
|
|
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
|
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
|
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
|
|
{/* Mobile time range select */}
|
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
|
<SelectTrigger
|
|
className="flex w-full min-w-40 @[767px]/card:hidden"
|
|
size="sm"
|
|
aria-label="Selecionar período"
|
|
>
|
|
<SelectValue placeholder="Selecionar período" />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
<SelectItem value="90d" className="rounded-lg">Últimos 90 dias</SelectItem>
|
|
<SelectItem value="30d" className="rounded-lg">Últimos 30 dias</SelectItem>
|
|
<SelectItem value="7d" className="rounded-lg">Últimos 7 dias</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Export button aligned at the end */}
|
|
<Button asChild size="sm" variant="outline" className="sm:ml-1">
|
|
<a
|
|
href={`/api/reports/tickets-by-channel.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
|
download
|
|
>
|
|
Exportar XLSX
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
|
{report === undefined ? (
|
|
<div className="flex h-[250px] items-center justify-center">
|
|
<Skeleton className="h-24 w-full" />
|
|
</div>
|
|
) : chartData.length === 0 || channels.length === 0 ? (
|
|
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
|
Sem dados suficientes no período selecionado.
|
|
</div>
|
|
) : (
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="aspect-auto h-[250px] w-full"
|
|
>
|
|
<AreaChart data={chartData}>
|
|
<defs>
|
|
{channels.map((channel: string) => (
|
|
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop
|
|
offset="5%"
|
|
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
|
stopOpacity={0.85}
|
|
/>
|
|
<stop
|
|
offset="95%"
|
|
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
|
stopOpacity={0.1}
|
|
/>
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={32}
|
|
tickFormatter={(value) => formatDateDM(new Date(value))}
|
|
/>
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
|
indicator="dot"
|
|
/>
|
|
}
|
|
/>
|
|
{channels
|
|
.slice()
|
|
.reverse()
|
|
.map((channel: string) => (
|
|
<Area
|
|
key={channel}
|
|
dataKey={channel}
|
|
type="natural"
|
|
fill={`url(#fill-${channel})`}
|
|
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
|
strokeWidth={2}
|
|
stackId="a"
|
|
name={
|
|
typeof chartConfig[channel]?.label === "string"
|
|
? (chartConfig[channel]?.label as string)
|
|
: channel
|
|
}
|
|
/>
|
|
))}
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export default ChartAreaInteractive
|