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

@ -1,14 +1,5 @@
import { cronJobs } from "convex/server"
import { api } from "./_generated/api"
const crons = cronJobs()
// Check hourly and the action will gate by America/Sao_Paulo hour
crons.interval(
"hours-usage-alerts-hourly",
{ hours: 1 },
api.alerts_actions.sendHoursUsageAlerts,
{}
)
export default crons

View file

@ -113,20 +113,39 @@ export const summary = query({
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect();
const result = await Promise.all(
queues.map(async (qItem) => {
const pending = await ctx.db
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
.collect();
const waiting = pending.filter((t) => {
const status = normalizeStatus(t.status);
return status === "PENDING" || status === "PAUSED";
}).length;
const open = pending.filter((t) => {
const status = normalizeStatus(t.status);
return status !== "RESOLVED";
}).length;
const breached = 0;
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
let pending = 0;
let inProgress = 0;
let paused = 0;
let breached = 0;
const now = Date.now();
for (const ticket of tickets) {
const status = normalizeStatus(ticket.status);
if (status === "PENDING") {
pending += 1;
} else if (status === "AWAITING_ATTENDANCE") {
inProgress += 1;
} else if (status === "PAUSED") {
paused += 1;
}
if (status !== "RESOLVED") {
const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null;
if (dueAt && dueAt < now) {
breached += 1;
}
}
}
return {
id: qItem._id,
name: renameQueueString(qItem.name),
pending,
inProgress,
paused,
breached,
};
})
);
return result;

35
docs/requests-status.md Normal file
View file

@ -0,0 +1,35 @@
# Solicitações mapeadas
## Tickets e fluxo
- [feito] Revisão dos status para Pendente / Em andamento / Pausado / Resolvido em listagens e componente de fila.
- [feito] Botões de play interno/externo com rastreio de horas.
- [feito] Pausa com motivo obrigatório registrado na timeline.
- [feito] Encerramento manual com diálogo atualizado e templates.
- [pendente] Encerramento automático após 3 tentativas de contato (não implementado).
## Comentários e notificações
- [feito] Comentários internos como padrão para equipe; públicos visíveis ao cliente.
- [feito] Exportação do histórico completo do ticket em PDF.
- [feito] E-mails automáticos para comentários públicos e encerramento.
- [não aplicável] Resumo com IA (feature solicitada, ainda não iniciada).
## Relatórios e contratos
- [feito] Relatório de horas internas x externas por cliente com exportação XLSX.
- [feito] Campo "Cliente avulso" e horas contratadas no cadastro de empresas, com alertas configuráveis.
- [removido] Alertas automáticos por e-mail de uso de horas (cron desativado a pedido).
## Inventário e dispositivos
- [feito] Unificação de máquinas em “Dispositivos”, suporte a desktops e celulares.
- [feito] Campos personalizados e templates de exportação no inventário.
- [aguardando uso] Tag de serviço pode ser adicionada como campo personalizado conforme necessidade.
## Perfis e acesso
- [feito] Perfis Admin/Agente/Gestor/Usuário com restrições de visualização por empresa.
- [feito] Motivo da troca de responsável registrado como comentário interno.
- [feito] Templates de comentário e encerramento gerenciáveis na área de configurações.
- [feito] Ajuste de horas manual com motivo e log.
## Itens adicionais
- [pendente] Dashboard personalizado para gestores (fora do escopo atual).
- [pendente] Integração com IA/assistente para resumo automático.
- [pendente] Gatilho automático para encerramento por falta de contato (ver item em Tickets e fluxo).

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

@ -198,7 +198,8 @@ export const ticketQueueSummarySchema = z.object({
id: z.string(),
name: z.string(),
pending: z.number(),
waiting: z.number(),
inProgress: z.number(),
paused: z.number(),
breached: z.number(),
})
export type TicketQueueSummary = z.infer<typeof ticketQueueSummarySchema>