chore: sync staging
This commit is contained in:
parent
c5ddd54a3e
commit
561b19cf66
610 changed files with 105285 additions and 1206 deletions
122
src/components/dashboard/dashboard-alerts-panel.tsx
Normal file
122
src/components/dashboard/dashboard-alerts-panel.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { AlertTriangle, BellRing } from "lucide-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 { cn } from "@/lib/utils"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type AlertEntry = {
|
||||
_id: string
|
||||
companyName: string
|
||||
usagePct: number
|
||||
threshold: number
|
||||
range: string
|
||||
recipients: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function DashboardAlertsPanel() {
|
||||
const { convexUserId, isAdmin, session } = useAuth()
|
||||
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
|
||||
const enabled = Boolean(convexUserId && isAdmin)
|
||||
const alerts = useQuery(
|
||||
api.alerts.list,
|
||||
enabled
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
limit: 5,
|
||||
}
|
||||
: "skip"
|
||||
) as AlertEntry[] | undefined
|
||||
|
||||
const items = useMemo(() => (alerts ?? []).slice(0, 5), [alerts])
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">Alertas automáticos</CardTitle>
|
||||
<CardDescription>Últimos avisos disparados para gestores</CardDescription>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/alerts">Configurar alertas</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!enabled ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<BellRing className="size-6" />
|
||||
<p>Apenas administradores podem visualizar os alertas disparados.</p>
|
||||
</div>
|
||||
) : alerts === undefined ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={`alerts-skeleton-${index}`} className="h-16 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum alerta disparado recentemente. Configure thresholds personalizados para acompanhar consumo de horas e SLAs.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((entry) => (
|
||||
<AlertRow key={entry._id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertRow({ entry }: { entry: AlertEntry }) {
|
||||
const exceeded = entry.usagePct >= entry.threshold
|
||||
const severityClass = exceeded ? "text-amber-600 bg-amber-50" : "text-emerald-600 bg-emerald-50"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-border/60 bg-white/90 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("rounded-full p-1", severityClass)}>
|
||||
<AlertTriangle className="size-4" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-neutral-800">{entry.companyName}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full border-border/60 px-3 py-1 text-xs">{entry.range}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-500">
|
||||
<span>
|
||||
Uso {entry.usagePct.toFixed(1)}% • Limite {entry.threshold}%
|
||||
</span>
|
||||
{entry.recipients.length ? (
|
||||
<span className="truncate">
|
||||
Destinatários: <strong>{entry.recipients.slice(0, 2).join(", ")}</strong>
|
||||
{entry.recipients.length > 2 ? ` +${entry.recipients.length - 2}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
{new Date(entry.createdAt).toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
483
src/components/dashboard/dashboard-hero.tsx
Normal file
483
src/components/dashboard/dashboard-hero.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"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 } 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)" },
|
||||
}
|
||||
|
||||
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(
|
||||
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs 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(
|
||||
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs 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 ? (
|
||||
`${overview.firstResponse.averageMinutes.toFixed(1)} min`
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
) : (
|
||||
<Skeleton className="h-8 w-28" />
|
||||
)}
|
||||
</CardTitle>
|
||||
{responseDelta ? (
|
||||
<CardAction className="px-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs 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(
|
||||
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs 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}
|
||||
/>
|
||||
<SlaAtRiskCard
|
||||
data={overview?.awaitingAction}
|
||||
isLoading={dashboardEnabled && overview === 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-neutral-900 underline-offset-4 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(
|
||||
"rounded-full px-3 py-1 text-xs 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") : 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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue