sistema-de-chamados/src/components/dashboard/dashboard-hero.tsx
2025-11-17 09:10:45 -03:00

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