diff --git a/convex/reports.ts b/convex/reports.ts index 698f6f8..0d5ae75 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -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 = {} + const resolved: Record = {} + + // 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({ 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 }) => { diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 82acb67..e94fee0 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { SectionCards } from "@/components/section-cards" import { SiteHeader } from "@/components/site-header" import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel" 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" export default function Dashboard() { @@ -20,7 +20,7 @@ export default function Dashboard() { >
- +
diff --git a/src/app/views/page.tsx b/src/app/views/page.tsx new file mode 100644 index 0000000..58f9f33 --- /dev/null +++ b/src/app/views/page.tsx @@ -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 ( + } + > +
+ {/* Canais por período (movido do dashboard) */} + + {/* Conjunto extra de gráficos relevantes (CSAT, filas) */} + +
+
+ ) +} + diff --git a/src/components/charts/chart-opened-resolved.tsx b/src/components/charts/chart-opened-resolved.tsx new file mode 100644 index 0000000..45aeda0 --- /dev/null +++ b/src/components/charts/chart-opened-resolved.tsx @@ -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 + } + + return ( + + + Abertos x Resolvidos + + Evolução diária nos últimos {data.rangeDays} dias + + +
+ + + 90 dias + 30 dias + 7 dias + +
+
+
+ + {data.series.length === 0 ? ( +
+ Sem dados suficientes no período selecionado. +
+ ) : ( + + + + + } + /> + + + + + )} +
+
+ ) +} + diff --git a/src/components/charts/views-charts.tsx b/src/components/charts/views-charts.tsx new file mode 100644 index 0000000..300109f --- /dev/null +++ b/src/components/charts/views-charts.tsx @@ -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 ( +
+ + +
+ ) +} + +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 + const chartData = (data.distribution ?? []).map((d) => ({ score: `Nota ${d.score}`, total: d.total })) + const chartConfig: any = { total: { label: "Respostas" } } + + return ( + + + CSAT - Distribuição + Frequência de respostas por nota + + + + + + {chartData.length === 0 ? ( +
+ Sem respostas no período. +
+ ) : ( + + + } /> + + + + + + )} +
+
+ ) +} + +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 + const chartData = (data.queueBreakdown ?? []).map((q) => ({ queue: q.name, open: q.open })) + const chartConfig: any = { open: { label: "Abertos" } } + + return ( + + + Filas com maior volume aberto + Distribuição atual por fila + + + + + + {chartData.length === 0 ? ( +
+ Sem filas com tickets abertos. +
+ ) : ( + + + + + } /> + + + + )} +
+
+ ) +} +