refine queue metrics and devices ui
This commit is contained in:
parent
1e45324460
commit
c2acd65764
11 changed files with 181 additions and 116 deletions
|
|
@ -1,14 +1,5 @@
|
||||||
import { cronJobs } from "convex/server"
|
import { cronJobs } from "convex/server"
|
||||||
import { api } from "./_generated/api"
|
|
||||||
|
|
||||||
const crons = cronJobs()
|
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
|
export default crons
|
||||||
|
|
|
||||||
|
|
@ -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 queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect();
|
||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
queues.map(async (qItem) => {
|
queues.map(async (qItem) => {
|
||||||
const pending = await ctx.db
|
const tickets = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
|
||||||
.collect();
|
.collect();
|
||||||
const waiting = pending.filter((t) => {
|
let pending = 0;
|
||||||
const status = normalizeStatus(t.status);
|
let inProgress = 0;
|
||||||
return status === "PENDING" || status === "PAUSED";
|
let paused = 0;
|
||||||
}).length;
|
let breached = 0;
|
||||||
const open = pending.filter((t) => {
|
const now = Date.now();
|
||||||
const status = normalizeStatus(t.status);
|
for (const ticket of tickets) {
|
||||||
return status !== "RESOLVED";
|
const status = normalizeStatus(ticket.status);
|
||||||
}).length;
|
if (status === "PENDING") {
|
||||||
const breached = 0;
|
pending += 1;
|
||||||
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
|
} 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;
|
return result;
|
||||||
|
|
|
||||||
35
docs/requests-status.md
Normal file
35
docs/requests-status.md
Normal 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).
|
||||||
|
|
@ -3520,13 +3520,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{device.authEmail ?? "E-mail não definido"}</span>
|
<span>{device.authEmail ?? "E-mail não definido"}</span>
|
||||||
{device.authEmail ? (
|
{device.authEmail ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyEmail}
|
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"
|
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"
|
title="Copiar e-mail do dispositivo"
|
||||||
aria-label="Copiar e-mail do dispositivo"
|
aria-label="Copiar e-mail do dispositivo"
|
||||||
>
|
>
|
||||||
<ClipboardCopy className="size-3.5" />
|
<ClipboardCopy className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -3541,12 +3541,17 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
{/* Campos personalizados (posicionado logo após métricas) */}
|
{/* Campos personalizados (posicionado logo após métricas) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
|
||||||
{(device.customFields ?? []).length}
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
||||||
</Badge>
|
{(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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" className="gap-2" onClick={() => setCustomFieldsEditorOpen(true)}>
|
<Button size="sm" variant="outline" className="gap-2" onClick={() => setCustomFieldsEditorOpen(true)}>
|
||||||
|
|
@ -3564,9 +3569,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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>
|
<span className="text-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{totalOpenTickets > 0 ? (
|
{totalOpenTickets > 0 ? (
|
||||||
|
|
@ -3630,6 +3625,16 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
UserCog,
|
UserCog,
|
||||||
Building2,
|
Building2,
|
||||||
|
Skyscraper,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Clock4,
|
Clock4,
|
||||||
Timer,
|
Timer,
|
||||||
|
|
@ -105,7 +106,7 @@ const navigation: NavigationGroup[] = [
|
||||||
{
|
{
|
||||||
title: "Empresas",
|
title: "Empresas",
|
||||||
url: "/admin/companies",
|
url: "/admin/companies",
|
||||||
icon: Building2,
|
icon: Skyscraper,
|
||||||
requiredRole: "admin",
|
requiredRole: "admin",
|
||||||
},
|
},
|
||||||
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
|
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
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 { useAuth } from "@/lib/auth-client"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
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 {
|
import {
|
||||||
ChartConfig,
|
ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
|
@ -25,6 +19,12 @@ import {
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis, Bar, BarChart, Pie, PieChart } from "recharts"
|
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 }
|
type CompanyRecord = { id: Id<"companies">; name: string }
|
||||||
|
|
||||||
|
|
@ -67,11 +67,20 @@ export function CompanyReport() {
|
||||||
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as CompanyRecord[] | undefined
|
) as CompanyRecord[] | undefined
|
||||||
|
|
||||||
|
const companyOptions = useMemo<SearchableComboboxOption[]>(
|
||||||
|
() =>
|
||||||
|
(companies ?? []).map((company) => ({
|
||||||
|
value: company.id as string,
|
||||||
|
label: company.name,
|
||||||
|
})),
|
||||||
|
[companies]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCompany && companies && companies.length > 0) {
|
if (!selectedCompany && companyOptions.length > 0) {
|
||||||
setSelectedCompany(companies[0].id)
|
setSelectedCompany(companyOptions[0]?.value ?? "")
|
||||||
}
|
}
|
||||||
}, [companies, selectedCompany])
|
}, [companyOptions, selectedCompany])
|
||||||
|
|
||||||
const report = useQuery(
|
const report = useQuery(
|
||||||
api.reports.companyOverview,
|
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>
|
<p className="text-sm text-neutral-500">Acompanhe tickets, inventário e colaboradores de um cliente específico.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany} disabled={!companies?.length}>
|
<div className="w-[260px]">
|
||||||
<SelectTrigger className="w-[220px] rounded-xl border-slate-200">
|
<SearchableCombobox
|
||||||
<SelectValue placeholder="Selecione a empresa" />
|
value={selectedCompany || null}
|
||||||
</SelectTrigger>
|
onValueChange={(value) => {
|
||||||
<SelectContent className="rounded-xl">
|
if (value) setSelectedCompany(value)
|
||||||
{(companies ?? []).map((company) => (
|
}}
|
||||||
<SelectItem key={company.id} value={company.id as string}>
|
options={companyOptions}
|
||||||
{company.name}
|
placeholder="Selecionar empresa"
|
||||||
</SelectItem>
|
searchPlaceholder="Buscar empresa..."
|
||||||
))}
|
emptyText="Nenhuma empresa encontrada."
|
||||||
</SelectContent>
|
disabled={!companyOptions.length}
|
||||||
</Select>
|
/>
|
||||||
|
</div>
|
||||||
<Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}>
|
<Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}>
|
||||||
<SelectTrigger className="w-[160px] rounded-xl border-slate-200">
|
<SelectTrigger className="w-[160px] rounded-xl border-slate-200">
|
||||||
<SelectValue placeholder="Período" />
|
<SelectValue placeholder="Período" />
|
||||||
|
|
@ -306,10 +316,8 @@ export function CompanyReport() {
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg font-semibold text-neutral-900">Tickets recentes (máximo 6)</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">Tickets recentes</CardTitle>
|
||||||
<CardDescription className="text-neutral-600">
|
<CardDescription className="text-neutral-600">Chamados em aberto para a empresa selecionada.</CardDescription>
|
||||||
Chamados em aberto para a empresa filtrada.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{openTickets.length === 0 ? (
|
{openTickets.length === 0 ? (
|
||||||
|
|
@ -317,11 +325,12 @@ export function CompanyReport() {
|
||||||
Nenhum chamado aberto no período selecionado.
|
Nenhum chamado aberto no período selecionado.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<div className="space-y-3">
|
||||||
{openTickets.map((ticket) => (
|
{openTickets.map((ticket) => (
|
||||||
<li
|
<Link
|
||||||
key={ticket.id}
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-neutral-900">
|
<p className="truncate text-sm font-semibold text-neutral-900">
|
||||||
|
|
@ -329,25 +338,25 @@ export function CompanyReport() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500">
|
||||||
Atualizado{" "}
|
Atualizado{" "}
|
||||||
{new Date(ticket.updatedAt).toLocaleString("pt-BR", {
|
{formatDistanceToNow(new Date(ticket.updatedAt), {
|
||||||
day: "2-digit",
|
addSuffix: true,
|
||||||
month: "short",
|
locale: ptBR,
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
|
<TicketPriorityPill
|
||||||
{ticket.priority}
|
priority={ticket.priority as TicketPriority}
|
||||||
</Badge>
|
className="h-8 rounded-full px-3 text-xs"
|
||||||
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
|
/>
|
||||||
{STATUS_LABELS[ticket.status] ?? ticket.status}
|
<TicketStatusBadge
|
||||||
</Badge>
|
status={ticket.status as TicketStatus}
|
||||||
|
className="h-8 px-3 text-xs font-semibold"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -544,7 +544,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
</DialogTrigger>
|
</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">
|
<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="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)}>
|
<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">
|
<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">
|
<DialogHeader className="gap-1.5 p-0">
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,9 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
id: "default",
|
id: "default",
|
||||||
name: "Geral",
|
name: "Geral",
|
||||||
pending: queueSummary.reduce((acc, item) => acc + item.pending, 0),
|
pending: queueSummary.reduce((acc, item) => acc + item.pending, 0),
|
||||||
waiting: queueSummary.reduce((acc, item) => acc + item.waiting, 0),
|
inProgress: queueSummary.reduce((acc, item) => acc + item.inProgress, 0),
|
||||||
breached: 0,
|
paused: queueSummary.reduce((acc, item) => acc + item.paused, 0),
|
||||||
|
breached: queueSummary.reduce((acc, item) => acc + item.breached, 0),
|
||||||
},
|
},
|
||||||
nextTicket: nextTicketUi,
|
nextTicket: nextTicketUi,
|
||||||
}
|
}
|
||||||
|
|
@ -127,11 +128,15 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
<span className="font-semibold text-neutral-900">{cardContext.queue.pending}</span>
|
<span className="font-semibold text-neutral-900">{cardContext.queue.pending}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Em espera</span>
|
<span>Em andamento</span>
|
||||||
<span className="font-semibold text-neutral-900">{cardContext.queue.waiting}</span>
|
<span className="font-semibold text-neutral-900">{cardContext.queue.inProgress}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<span className="font-semibold text-red-600">{cardContext.queue.breached}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -221,10 +221,6 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
||||||
</span>
|
</span>
|
||||||
{ratedAtRelative ? ` • ${ratedAtRelative}` : null}
|
{ratedAtRelative ? ` • ${ratedAtRelative}` : null}
|
||||||
</p>
|
</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}
|
) : null}
|
||||||
{canSubmit ? (
|
{canSubmit ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(22rem,1fr))]">
|
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(22rem,1fr))]">
|
||||||
{data.map((queue) => {
|
{data.map((queue) => {
|
||||||
const total = queue.pending + queue.waiting
|
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
||||||
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
|
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={queue.id}
|
key={queue.id}
|
||||||
|
|
@ -52,7 +52,7 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 sm:space-y-4">
|
<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">
|
<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">
|
<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
|
Pendentes
|
||||||
|
|
@ -63,25 +63,28 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
||||||
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
|
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
|
||||||
{queue.waiting}
|
{queue.inProgress}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
||||||
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
|
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
|
||||||
{queue.breached}
|
{queue.paused}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||||
<span className="mt-2 block text-xs text-neutral-500">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -194,13 +194,14 @@ export const ticketWithDetailsSchema = ticketSchema.extend({
|
||||||
})
|
})
|
||||||
export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema>
|
export type TicketWithDetails = z.infer<typeof ticketWithDetailsSchema>
|
||||||
|
|
||||||
export const ticketQueueSummarySchema = z.object({
|
export const ticketQueueSummarySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
pending: z.number(),
|
pending: z.number(),
|
||||||
waiting: z.number(),
|
inProgress: z.number(),
|
||||||
breached: z.number(),
|
paused: z.number(),
|
||||||
})
|
breached: z.number(),
|
||||||
|
})
|
||||||
export type TicketQueueSummary = z.infer<typeof ticketQueueSummarySchema>
|
export type TicketQueueSummary = z.infer<typeof ticketQueueSummarySchema>
|
||||||
|
|
||||||
export const ticketPlayContextSchema = z.object({
|
export const ticketPlayContextSchema = z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue