490 lines
20 KiB
TypeScript
490 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { type ReactNode } from "react"
|
|
import Link from "next/link"
|
|
import { useQuery } from "convex/react"
|
|
import { IconClockHour4, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
|
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
|
import { formatDistanceToNow } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
|
|
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 { cn, formatDateDM, formatDateDMY, formatMinutesHuman } from "@/lib/utils"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card"
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
|
|
type DashboardOverview = {
|
|
newTickets?: { last24h: number; previous24h: number; trendPercentage: number | null }
|
|
inProgress?: { current: number; previousSnapshot: number; trendPercentage: number | null }
|
|
firstResponse?: { averageMinutes: number | null; deltaMinutes: number | null; responsesCount: number }
|
|
awaitingAction?: { total: number; atRisk: number }
|
|
resolution?: { resolvedLast7d: number; previousResolved: number; rate: number | null; deltaPercentage: number | null }
|
|
}
|
|
|
|
type QueueTrendResponse = {
|
|
rangeDays: number
|
|
queues: Array<{
|
|
id: string
|
|
name: string
|
|
openedTotal: number
|
|
resolvedTotal: number
|
|
series: Array<{ date: string; opened: number; resolved: number }>
|
|
}>
|
|
}
|
|
|
|
const queueSparkConfig = {
|
|
opened: { label: "Novos", color: "var(--chart-1)" },
|
|
resolved: { label: "Resolvidos", color: "var(--chart-2)" },
|
|
}
|
|
|
|
const metricBadgeClass =
|
|
"gap-1 rounded-full border-border/60 px-2.5 py-0.5 text-[11px] sm:px-3 sm:py-1 sm:text-xs"
|
|
|
|
export function DashboardHero() {
|
|
const { session, convexUserId, isStaff } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
const dashboardEnabled = Boolean(isStaff && convexUserId)
|
|
const overview = useQuery(
|
|
api.reports.dashboardOverview,
|
|
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as DashboardOverview | undefined
|
|
const newTicketsTrend = overview?.newTickets
|
|
? {
|
|
delta: overview.newTickets.trendPercentage,
|
|
label:
|
|
overview.newTickets.trendPercentage === null
|
|
? "Sem comparativo"
|
|
: `${overview.newTickets.trendPercentage >= 0 ? "+" : ""}${overview.newTickets.trendPercentage.toFixed(1)}% vs. 24h anteriores`,
|
|
}
|
|
: null
|
|
|
|
const inProgressTrend = overview?.inProgress
|
|
? {
|
|
delta: overview.inProgress.trendPercentage,
|
|
label:
|
|
overview.inProgress.trendPercentage === null
|
|
? "Sem histórico"
|
|
: `${overview.inProgress.trendPercentage >= 0 ? "+" : ""}${overview.inProgress.trendPercentage.toFixed(1)}% vs. última medição`,
|
|
}
|
|
: null
|
|
|
|
const responseDelta = overview?.firstResponse
|
|
? (() => {
|
|
const delta = overview.firstResponse.deltaMinutes
|
|
if (delta === null) return { delta, label: "Sem comparação" }
|
|
return { delta, label: `${delta > 0 ? "+" : ""}${delta.toFixed(1)} min` }
|
|
})()
|
|
: null
|
|
|
|
const resolutionInfo = overview?.resolution
|
|
? {
|
|
delta: overview.resolution.deltaPercentage ?? null,
|
|
label:
|
|
overview.resolution.deltaPercentage === null
|
|
? "Sem histórico"
|
|
: `${overview.resolution.deltaPercentage >= 0 ? "+" : ""}${overview.resolution.deltaPercentage.toFixed(1)}%`,
|
|
rateLabel:
|
|
overview.resolution.rate !== null
|
|
? `${overview.resolution.rate.toFixed(1)}% dos tickets resolvidos`
|
|
: "Taxa indisponível",
|
|
}
|
|
: { delta: null, label: "Sem histórico", rateLabel: "Taxa indisponível" }
|
|
|
|
const newTicketsFooter = (() => {
|
|
if (!newTicketsTrend) return "Aguardando dados"
|
|
if (newTicketsTrend.delta === null) return "Sem comparativo recente"
|
|
return newTicketsTrend.delta >= 0 ? "Acima das 24h anteriores" : "Abaixo das 24h anteriores"
|
|
})()
|
|
|
|
const inProgressFooter = (() => {
|
|
if (!inProgressTrend) return "Monitoramento aguardando histórico"
|
|
if (inProgressTrend.delta === null) return "Sem histórico recente"
|
|
return inProgressTrend.delta >= 0 ? "Acima da última medição" : "Abaixo da última medição"
|
|
})()
|
|
|
|
const responseFooter = (() => {
|
|
if (!responseDelta) return "Sem dados suficientes para comparação"
|
|
if (responseDelta.delta === null) return "Sem comparação recente"
|
|
return responseDelta.delta <= 0 ? "Melhor que a última medição" : "Pior que a última medição"
|
|
})()
|
|
|
|
const resolutionFooter = resolutionInfo?.rateLabel ?? "Taxa indisponível no momento."
|
|
|
|
return (
|
|
<div className="space-y-6 px-4 lg:px-6">
|
|
<div className="grid gap-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
|
|
<CardHeader className="gap-3 px-0">
|
|
<CardDescription>Novos tickets (24h)</CardDescription>
|
|
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
|
|
{overview?.newTickets ? overview.newTickets.last24h : <Skeleton className="h-8 w-20" />}
|
|
</CardTitle>
|
|
{newTicketsTrend ? (
|
|
<CardAction className="px-0">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
metricBadgeClass,
|
|
"font-semibold",
|
|
newTicketsTrend.delta === null
|
|
? "text-neutral-500"
|
|
: newTicketsTrend.delta < 0
|
|
? "text-emerald-600"
|
|
: "text-amber-600"
|
|
)}
|
|
>
|
|
{newTicketsTrend.delta !== null && newTicketsTrend.delta < 0 ? (
|
|
<IconTrendingDown className="size-3.5" />
|
|
) : (
|
|
<IconTrendingUp className="size-3.5" />
|
|
)}
|
|
{newTicketsTrend.label}
|
|
</Badge>
|
|
</CardAction>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
|
|
<span>{newTicketsFooter}</span>
|
|
<span className="text-xs text-neutral-400">Base: entradas registradas nas últimas 24h.</span>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
|
|
<CardHeader className="gap-3 px-0">
|
|
<CardDescription>Em atendimento</CardDescription>
|
|
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
|
|
{overview?.inProgress ? overview.inProgress.current : <Skeleton className="h-8 w-14" />}
|
|
</CardTitle>
|
|
{inProgressTrend ? (
|
|
<CardAction className="px-0">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
metricBadgeClass,
|
|
"font-semibold",
|
|
inProgressTrend.delta === null
|
|
? "text-neutral-500"
|
|
: inProgressTrend.delta > 0
|
|
? "text-amber-600"
|
|
: "text-emerald-600"
|
|
)}
|
|
>
|
|
{inProgressTrend.delta !== null && inProgressTrend.delta > 0 ? (
|
|
<IconTrendingUp className="size-3.5" />
|
|
) : (
|
|
<IconTrendingDown className="size-3.5" />
|
|
)}
|
|
{inProgressTrend.label}
|
|
</Badge>
|
|
</CardAction>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
|
|
<span>{inProgressFooter}</span>
|
|
<span className="text-xs text-neutral-400">Base: tickets ativos em SLA.</span>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
|
|
<CardHeader className="gap-3 px-0">
|
|
<CardDescription>Tempo médio (1ª resposta)</CardDescription>
|
|
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
|
|
{overview?.firstResponse ? (
|
|
overview.firstResponse.averageMinutes !== null ? (
|
|
formatMinutesHuman(overview.firstResponse.averageMinutes)
|
|
) : (
|
|
"—"
|
|
)
|
|
) : (
|
|
<Skeleton className="h-8 w-28" />
|
|
)}
|
|
</CardTitle>
|
|
{responseDelta ? (
|
|
<CardAction className="px-0">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
metricBadgeClass,
|
|
"font-semibold",
|
|
responseDelta.delta === null
|
|
? "text-neutral-500"
|
|
: responseDelta.delta > 0
|
|
? "text-amber-600"
|
|
: "text-emerald-600"
|
|
)}
|
|
>
|
|
{responseDelta.delta !== null && responseDelta.delta > 0 ? (
|
|
<IconTrendingUp className="size-3.5" />
|
|
) : (
|
|
<IconTrendingDown className="size-3.5" />
|
|
)}
|
|
{responseDelta.label}
|
|
</Badge>
|
|
</CardAction>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
|
|
<span>{responseFooter}</span>
|
|
<span className="text-xs text-neutral-400">Média móvel dos últimos 7 dias.</span>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
|
|
<CardHeader className="gap-3 px-0">
|
|
<CardDescription>Resolvidos (7 dias)</CardDescription>
|
|
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
|
|
{overview?.resolution ? overview.resolution.resolvedLast7d : <Skeleton className="h-8 w-12" />}
|
|
</CardTitle>
|
|
<CardAction className="px-0">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
metricBadgeClass,
|
|
"font-semibold",
|
|
resolutionInfo?.delta === null
|
|
? "text-neutral-500"
|
|
: resolutionInfo?.delta !== null && resolutionInfo.delta < 0
|
|
? "text-amber-600"
|
|
: "text-emerald-600"
|
|
)}
|
|
>
|
|
{resolutionInfo?.delta !== null && resolutionInfo.delta < 0 ? (
|
|
<IconTrendingDown className="size-3.5" />
|
|
) : (
|
|
<IconTrendingUp className="size-3.5" />
|
|
)}
|
|
{resolutionInfo?.label}
|
|
</Badge>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
|
|
<span>{resolutionFooter}</span>
|
|
<span className="text-xs text-neutral-400">Comparação com os 7 dias anteriores.</span>
|
|
</CardFooter>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function DashboardQueueInsights() {
|
|
const { session, convexUserId, isStaff } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
const dashboardEnabled = Boolean(isStaff && convexUserId)
|
|
const overview = useQuery(
|
|
api.reports.dashboardOverview,
|
|
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as DashboardOverview | undefined
|
|
const queueTrend = useQuery(
|
|
api.reports.queueLoadTrend,
|
|
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: "30d", limit: 3 } : "skip"
|
|
) as QueueTrendResponse | undefined
|
|
|
|
return (
|
|
<div className="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
|
|
<QueueSparklineRow
|
|
data={queueTrend?.queues}
|
|
isLoading={dashboardEnabled && queueTrend === undefined}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function QueueSparklineRow({
|
|
data,
|
|
isLoading,
|
|
}: {
|
|
data?: QueueTrendResponse["queues"]
|
|
isLoading: boolean
|
|
}) {
|
|
return (
|
|
<Card className="h-full rounded-3xl border border-border/60 bg-white/90 shadow-sm">
|
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="text-base font-semibold">Filas com maior volume</CardTitle>
|
|
<CardDescription>Comparativo diário de entradas x resolvidos</CardDescription>
|
|
</div>
|
|
<Link
|
|
href="/queues/overview"
|
|
className="text-sm font-semibold text-[#006879] underline-offset-4 transition-colors hover:text-[#004d5a] hover:underline"
|
|
>
|
|
Abrir painel de filas
|
|
</Link>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<Skeleton key={`queue-sparkline-${index}`} className="h-56 w-full rounded-2xl" />
|
|
))}
|
|
</div>
|
|
) : !data || data.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/40 p-6 text-center text-sm text-muted-foreground">
|
|
Ainda não há dados suficientes para exibir a tendência. Continue alimentando as filas ou reduza o período.
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
|
|
{data.map((queue) => (
|
|
<QueueSparklineCard key={queue.id} queue={queue} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function QueueSparklineCard({
|
|
queue,
|
|
}: {
|
|
queue: Required<QueueTrendResponse>["queues"][number]
|
|
}) {
|
|
const sanitizedId = queue.id.replace(/[^a-zA-Z0-9_-]/g, "")
|
|
const latest = queue.series.length > 0 ? queue.series[queue.series.length - 1] : null
|
|
const net = queue.openedTotal - queue.resolvedTotal
|
|
const netLabel =
|
|
net === 0 ? "Estável" : `${net > 0 ? "+" : ""}${net} no período`
|
|
const lastUpdated = latest
|
|
? formatDistanceToNow(new Date(latest.date), { addSuffix: true, locale: ptBR })
|
|
: null
|
|
|
|
return (
|
|
<Card className="rounded-2xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-4 shadow-sm">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<p className="text-sm font-semibold text-neutral-900">{queue.name}</p>
|
|
<p className="text-xs text-neutral-500">
|
|
Última movimentação {lastUpdated ?? "—"}
|
|
</p>
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
metricBadgeClass,
|
|
"font-medium",
|
|
net > 0 ? "text-amber-600" : net < 0 ? "text-emerald-600" : "text-neutral-500"
|
|
)}
|
|
>
|
|
{netLabel}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<div>
|
|
<p className="text-xs uppercase text-neutral-500">Entraram</p>
|
|
<p className="text-lg font-semibold text-neutral-900">{queue.openedTotal}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase text-neutral-500">Resolvidos</p>
|
|
<p className="text-lg font-semibold text-neutral-900">{queue.resolvedTotal}</p>
|
|
</div>
|
|
</div>
|
|
<ChartContainer config={queueSparkConfig} className="h-32 w-full">
|
|
<AreaChart data={queue.series}>
|
|
<defs>
|
|
<linearGradient id={`opened-${sanitizedId}`} x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="5%" stopColor="var(--color-opened)" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="var(--color-opened)" stopOpacity={0.05} />
|
|
</linearGradient>
|
|
<linearGradient id={`resolved-${sanitizedId}`} x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="5%" stopColor="var(--color-resolved)" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="var(--color-resolved)" stopOpacity={0.05} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.2} />
|
|
<XAxis dataKey="date" tickFormatter={(value) => formatDateDM(new Date(value))} tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
|
valueFormatter={(value) =>
|
|
typeof value === "number" ? value.toLocaleString("pt-BR") : String(value ?? "")
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="opened"
|
|
stroke="var(--color-opened)"
|
|
fill={`url(#opened-${sanitizedId})`}
|
|
strokeWidth={2}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="resolved"
|
|
stroke="var(--color-resolved)"
|
|
fill={`url(#resolved-${sanitizedId})`}
|
|
strokeWidth={2}
|
|
/>
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export function SlaAtRiskCard({
|
|
data,
|
|
isLoading,
|
|
}: {
|
|
data?: { total: number; atRisk: number }
|
|
isLoading: boolean
|
|
}) {
|
|
return (
|
|
<Card className="h-full rounded-3xl border border-border/60 bg-white/90 shadow-sm">
|
|
<CardHeader>
|
|
<CardTitle className="text-base font-semibold">SLA em risco</CardTitle>
|
|
<CardDescription>Tickets com solução prevista nas próximas horas</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="rounded-2xl bg-gradient-to-br from-amber-50 to-amber-100/60 p-3 shadow-inner">
|
|
<IconClockHour4 className="size-8 text-amber-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-3xl font-semibold text-neutral-900">
|
|
{isLoading ? <Skeleton className="h-7 w-16" /> : data?.atRisk ?? 0}
|
|
</p>
|
|
<p className="text-sm text-neutral-500">
|
|
de {isLoading ? "—" : data?.total ?? 0} tickets ativos
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-dashed border-border/60 bg-amber-50/70 p-3 text-sm text-amber-900">
|
|
Foque nesses atendimentos para preservar o SLA. Utilize o filtro “em risco” na lista.
|
|
</div>
|
|
<ButtonLink href="/tickets?status=AWAITING_ATTENDANCE&priority=URGENT">
|
|
Priorizar agora
|
|
</ButtonLink>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function ButtonLink({ href, children }: { href: string; children: ReactNode }) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
className="inline-flex items-center justify-center rounded-2xl border border-neutral-900 bg-neutral-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/30"
|
|
>
|
|
{children}
|
|
</Link>
|
|
)
|
|
}
|