refine queue metrics and devices ui

This commit is contained in:
Esdras Renan 2025-11-04 19:53:54 -03:00
parent 1e45324460
commit c2acd65764
11 changed files with 181 additions and 116 deletions

View file

@ -3520,13 +3520,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{device.authEmail ?? "E-mail não definido"}</span>
{device.authEmail ? (
<button
type="button"
onClick={copyEmail}
className="inline-flex items-center rounded p-1 text-neutral-500 transition hover:bg-slate-100 hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
title="Copiar e-mail do dispositivo"
aria-label="Copiar e-mail do dispositivo"
>
<button
type="button"
onClick={copyEmail}
className="inline-flex items-center rounded-md p-1 text-neutral-500 transition hover:bg-[#00d6eb]/15 hover:text-[#0a4760] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40 focus-visible:ring-offset-2"
title="Copiar e-mail do dispositivo"
aria-label="Copiar e-mail do dispositivo"
>
<ClipboardCopy className="size-3.5" />
</button>
) : null}
@ -3541,12 +3541,17 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
{/* Campos personalizados (posicionado logo após métricas) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
{(device.customFields ?? []).length}
</Badge>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
{(device.customFields ?? []).length}
</Badge>
</div>
{(!device.customFields || device.customFields.length === 0) ? (
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
) : null}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="gap-2" onClick={() => setCustomFieldsEditorOpen(true)}>
@ -3564,9 +3569,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
))}
</div>
) : (
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
)}
) : null}
</div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
@ -3587,18 +3590,10 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<div className="flex h-10 min-w-[56px] items-center justify-center rounded-xl border border-[color:var(--accent)] bg-white px-3 text-[color:var(--accent-foreground)] shadow-sm">
<span className="text-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
</div>
{deviceTicketsHref ? (
<Link
href={deviceTicketsHref}
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
) : null}
</div>
</div>
{totalOpenTickets > 0 ? (
@ -3630,6 +3625,16 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
})}
</div>
) : null}
{deviceTicketsHref ? (
<div className="mt-4">
<Link
href={deviceTicketsHref}
className="text-xs font-semibold text-[color:var(--accent-foreground)] underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">

View file

@ -11,6 +11,7 @@ import {
PanelsTopLeft,
UserCog,
Building2,
Skyscraper,
Waypoints,
Clock4,
Timer,
@ -105,7 +106,7 @@ const navigation: NavigationGroup[] = [
{
title: "Empresas",
url: "/admin/companies",
icon: Building2,
icon: Skyscraper,
requiredRole: "admin",
},
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },

View file

@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -8,14 +9,7 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
ChartConfig,
ChartContainer,
@ -25,6 +19,12 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart"
import { Area, AreaChart, CartesianGrid, XAxis, Bar, BarChart, Pie, PieChart } from "recharts"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
type CompanyRecord = { id: Id<"companies">; name: string }
@ -67,11 +67,20 @@ export function CompanyReport() {
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as CompanyRecord[] | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(
() =>
(companies ?? []).map((company) => ({
value: company.id as string,
label: company.name,
})),
[companies]
)
useEffect(() => {
if (!selectedCompany && companies && companies.length > 0) {
setSelectedCompany(companies[0].id)
if (!selectedCompany && companyOptions.length > 0) {
setSelectedCompany(companyOptions[0]?.value ?? "")
}
}, [companies, selectedCompany])
}, [companyOptions, selectedCompany])
const report = useQuery(
api.reports.companyOverview,
@ -142,18 +151,19 @@ export function CompanyReport() {
<p className="text-sm text-neutral-500">Acompanhe tickets, inventário e colaboradores de um cliente específico.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={selectedCompany} onValueChange={setSelectedCompany} disabled={!companies?.length}>
<SelectTrigger className="w-[220px] rounded-xl border-slate-200">
<SelectValue placeholder="Selecione a empresa" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(companies ?? []).map((company) => (
<SelectItem key={company.id} value={company.id as string}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-[260px]">
<SearchableCombobox
value={selectedCompany || null}
onValueChange={(value) => {
if (value) setSelectedCompany(value)
}}
options={companyOptions}
placeholder="Selecionar empresa"
searchPlaceholder="Buscar empresa..."
emptyText="Nenhuma empresa encontrada."
disabled={!companyOptions.length}
/>
</div>
<Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}>
<SelectTrigger className="w-[160px] rounded-xl border-slate-200">
<SelectValue placeholder="Período" />
@ -306,10 +316,8 @@ export function CompanyReport() {
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Tickets recentes (máximo 6)</CardTitle>
<CardDescription className="text-neutral-600">
Chamados em aberto para a empresa filtrada.
</CardDescription>
<CardTitle className="text-lg font-semibold text-neutral-900">Tickets recentes</CardTitle>
<CardDescription className="text-neutral-600">Chamados em aberto para a empresa selecionada.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{openTickets.length === 0 ? (
@ -317,11 +325,12 @@ export function CompanyReport() {
Nenhum chamado aberto no período selecionado.
</p>
) : (
<ul className="space-y-3">
<div className="space-y-3">
{openTickets.map((ticket) => (
<li
<Link
key={ticket.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm"
href={`/tickets/${ticket.id}`}
className="group flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300 focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-900">
@ -329,25 +338,25 @@ export function CompanyReport() {
</p>
<p className="text-xs text-neutral-500">
Atualizado{" "}
{new Date(ticket.updatedAt).toLocaleString("pt-BR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
{formatDistanceToNow(new Date(ticket.updatedAt), {
addSuffix: true,
locale: ptBR,
})}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
{ticket.priority}
</Badge>
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
{STATUS_LABELS[ticket.status] ?? ticket.status}
</Badge>
<TicketPriorityPill
priority={ticket.priority as TicketPriority}
className="h-8 rounded-full px-3 text-xs"
/>
<TicketStatusBadge
status={ticket.status as TicketStatus}
className="h-8 px-3 text-xs font-semibold"
/>
</div>
</li>
</Link>
))}
</ul>
</div>
)}
</CardContent>
</Card>

View file

@ -544,7 +544,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</DialogTrigger>
<DialogContent className="max-w-4xl gap-0 overflow-hidden rounded-3xl border border-slate-200 bg-white p-0 shadow-2xl lg:max-w-5xl">
<div className="max-h-[88vh] overflow-y-auto">
<div className="space-y-5 px-6 py-7 sm:px-8 md:px-10">
<div className="space-y-5 px-6 pt-7 pb-12 sm:px-8 md:px-10">
<form className="space-y-6" onSubmit={form.handleSubmit(submit)}>
<div className="flex flex-col gap-4 border-b border-slate-200 pb-5 md:flex-row md:items-start md:justify-between">
<DialogHeader className="gap-1.5 p-0">

View file

@ -64,8 +64,9 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
id: "default",
name: "Geral",
pending: queueSummary.reduce((acc, item) => acc + item.pending, 0),
waiting: queueSummary.reduce((acc, item) => acc + item.waiting, 0),
breached: 0,
inProgress: queueSummary.reduce((acc, item) => acc + item.inProgress, 0),
paused: queueSummary.reduce((acc, item) => acc + item.paused, 0),
breached: queueSummary.reduce((acc, item) => acc + item.breached, 0),
},
nextTicket: nextTicketUi,
}
@ -127,11 +128,15 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
<span className="font-semibold text-neutral-900">{cardContext.queue.pending}</span>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.waiting}</span>
<span>Em andamento</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.inProgress}</span>
</div>
<div className="flex items-center justify-between">
<span>SLA violado</span>
<span>Pausados</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.paused}</span>
</div>
<div className="flex items-center justify-between">
<span>Fora do SLA</span>
<span className="font-semibold text-red-600">{cardContext.queue.breached}</span>
</div>
</div>

View file

@ -221,10 +221,6 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
</span>
{ratedAtRelative ? `${ratedAtRelative}` : null}
</p>
) : viewerIsStaff ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-neutral-600">
Nenhuma avaliação registrada ainda.
</div>
) : null}
{canSubmit ? (
<div className="space-y-2">

View file

@ -38,8 +38,8 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
return (
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(22rem,1fr))]">
{data.map((queue) => {
const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
const totalOpen = queue.pending + queue.inProgress + queue.paused
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
return (
<Card
key={queue.id}
@ -52,7 +52,7 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
Pendentes
@ -63,25 +63,28 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
</div>
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
Aguardando resposta
Em andamento
</p>
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
{queue.waiting}
{queue.inProgress}
</p>
</div>
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm sm:col-span-2 lg:px-4 lg:py-3">
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
Violados
Pausados
</p>
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
{queue.breached}
{queue.paused}
</p>
</div>
</div>
<div className="pt-1">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% com SLA violado nesta fila
{breachPercent}% dos chamados da fila estão fora do SLA
</span>
<span className="mt-1 block text-xs text-neutral-400">
Em atraso: {queue.breached}
</span>
</div>
</CardContent>

View file

@ -194,13 +194,14 @@ export const ticketWithDetailsSchema = ticketSchema.extend({
})
export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema>
export const ticketQueueSummarySchema = z.object({
id: z.string(),
name: z.string(),
pending: z.number(),
waiting: z.number(),
breached: z.number(),
})
export const ticketQueueSummarySchema = z.object({
id: z.string(),
name: z.string(),
pending: z.number(),
inProgress: z.number(),
paused: z.number(),
breached: z.number(),
})
export type TicketQueueSummary = z.infer<typeof ticketQueueSummarySchema>
export const ticketPlayContextSchema = z.object({