views: criar página /views com gráficos (Canais movido do dashboard, CSAT distribuição, Filas abertas); dashboard: trocar por gráfico Abertos x Resolvidos (últimos 7/30/90 dias); reports: nova query openedResolvedByDay
This commit is contained in:
parent
5b14ecbe0f
commit
88b65c3e15
5 changed files with 317 additions and 2 deletions
|
|
@ -226,6 +226,52 @@ export const csatOverview = query({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const openedResolvedByDay = query({
|
||||||
|
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||||
|
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||||
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
|
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
|
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||||
|
|
||||||
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
|
const end = new Date();
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endMs = end.getTime() + ONE_DAY_MS;
|
||||||
|
const startMs = endMs - days * ONE_DAY_MS;
|
||||||
|
|
||||||
|
const opened: Record<string, number> = {}
|
||||||
|
const resolved: Record<string, number> = {}
|
||||||
|
|
||||||
|
// pre-fill buckets
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(endMs - (i + 1) * ONE_DAY_MS)
|
||||||
|
const key = formatDateKey(d.getTime())
|
||||||
|
opened[key] = 0
|
||||||
|
resolved[key] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of tickets) {
|
||||||
|
if (t.createdAt >= startMs && t.createdAt < endMs) {
|
||||||
|
const key = formatDateKey(t.createdAt)
|
||||||
|
opened[key] = (opened[key] ?? 0) + 1
|
||||||
|
}
|
||||||
|
if (t.resolvedAt && t.resolvedAt >= startMs && t.resolvedAt < endMs) {
|
||||||
|
const key = formatDateKey(t.resolvedAt)
|
||||||
|
resolved[key] = (resolved[key] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: Array<{ date: string; opened: number; resolved: number }> = []
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(endMs - (i + 1) * ONE_DAY_MS)
|
||||||
|
const key = formatDateKey(d.getTime())
|
||||||
|
series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rangeDays: days, series }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const backlogOverview = query({
|
export const backlogOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
||||||
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { SectionCards } from "@/components/section-cards"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
||||||
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
||||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
import { ChartOpenedResolved } from "@/components/charts/chart-opened-resolved"
|
||||||
import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
|
import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
|
@ -20,7 +20,7 @@ export default function Dashboard() {
|
||||||
>
|
>
|
||||||
<SectionCards />
|
<SectionCards />
|
||||||
<div className="grid gap-6 px-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:px-6 lg:[&>*]:min-w-0">
|
<div className="grid gap-6 px-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:px-6 lg:[&>*]:min-w-0">
|
||||||
<ChartAreaInteractive />
|
<ChartOpenedResolved />
|
||||||
<RecentTicketsPanel />
|
<RecentTicketsPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 lg:px-6">
|
<div className="px-4 lg:px-6">
|
||||||
|
|
|
||||||
22
src/app/views/page.tsx
Normal file
22
src/app/views/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||||
|
import { ViewsCharts } from "@/components/charts/views-charts"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default function ViewsPage() {
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={<SiteHeader title="Visualizações" lead="Relatórios visuais e insights" />}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-6 px-4 pb-12 lg:px-6">
|
||||||
|
{/* Canais por período (movido do dashboard) */}
|
||||||
|
<ChartAreaInteractive />
|
||||||
|
{/* Conjunto extra de gráficos relevantes (CSAT, filas) */}
|
||||||
|
<ViewsCharts />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
112
src/components/charts/chart-opened-resolved.tsx
Normal file
112
src/components/charts/chart-opened-resolved.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CartesianGrid, Line, LineChart, 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 { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
type SeriesPoint = { date: string; opened: number; resolved: number }
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
opened: { label: "Abertos", color: "var(--chart-1)" },
|
||||||
|
resolved: { label: "Resolvidos", color: "var(--chart-2)" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function ChartOpenedResolved() {
|
||||||
|
const [timeRange, setTimeRange] = React.useState("30d")
|
||||||
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
const data = 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: SeriesPoint[] } | undefined
|
||||||
|
|
||||||
|
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Skeleton className="h-[300px] w-full" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="@container/card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Abertos x Resolvidos</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Evolução diária nos últimos {data.rangeDays} dias
|
||||||
|
</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 @[767px]/card: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="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
|
{data.series.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">
|
||||||
|
<LineChart data={data.series} margin={{ left: 12, right: 12 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={32}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="line" />}
|
||||||
|
/>
|
||||||
|
<Line dataKey="opened" type="natural" stroke="var(--color-opened)" strokeWidth={2} dot={false} />
|
||||||
|
<Line dataKey="resolved" type="natural" stroke="var(--color-resolved)" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
135
src/components/charts/views-charts.tsx
Normal file
135
src/components/charts/views-charts.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Pie, PieChart, LabelList, Bar, BarChart, 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 { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export function ViewsCharts() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<CsatPie />
|
||||||
|
<QueuesOpenBar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CsatPie() {
|
||||||
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
const data = useQuery(
|
||||||
|
api.reports.csatOverview,
|
||||||
|
convexUserId
|
||||||
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
||||||
|
: "skip"
|
||||||
|
) as { totalSurveys: number; distribution: { score: number; total: number }[] } | undefined
|
||||||
|
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
if (!data) return <Skeleton className="h-[300px] w-full" />
|
||||||
|
const chartData = (data.distribution ?? []).map((d) => ({ score: `Nota ${d.score}`, total: d.total }))
|
||||||
|
const chartConfig: any = { total: { label: "Respostas" } }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle>CSAT - Distribuição</CardTitle>
|
||||||
|
<CardDescription>Frequência de respostas por nota</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<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>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pb-0">
|
||||||
|
{chartData.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 respostas no período.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px] [&_.recharts-text]:fill-background">
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="total" hideLabel />} />
|
||||||
|
<Pie data={chartData} dataKey="total" nameKey="score">
|
||||||
|
<LabelList dataKey="score" className="fill-background" stroke="none" fontSize={12} />
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueuesOpenBar() {
|
||||||
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
const data = useQuery(
|
||||||
|
api.reports.slaOverview,
|
||||||
|
convexUserId
|
||||||
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
||||||
|
: "skip"
|
||||||
|
) as { queueBreakdown: { id: string; name: string; open: number }[] } | undefined
|
||||||
|
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
if (!data) return <Skeleton className="h-[300px] w-full" />
|
||||||
|
const chartData = (data.queueBreakdown ?? []).map((q) => ({ queue: q.name, open: q.open }))
|
||||||
|
const chartConfig: any = { open: { label: "Abertos" } }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Filas com maior volume aberto</CardTitle>
|
||||||
|
<CardDescription>Distribuição atual por fila</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<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>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.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 filas com tickets abertos.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||||
|
<BarChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="queue" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="open" hideLabel />} />
|
||||||
|
<Bar dataKey="open" radius={6} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue