From 96a6f73e30a7d128b5015df228a594d686a1ad12 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 20 Oct 2025 19:54:36 -0300 Subject: [PATCH] style: restyle ticket details panel --- .../tickets/ticket-details-panel.tsx | 310 +++++++++++++----- .../tickets/ticket-summary-header.tsx | 7 +- 2 files changed, 227 insertions(+), 90 deletions(-) diff --git a/src/components/tickets/ticket-details-panel.tsx b/src/components/tickets/ticket-details-panel.tsx index 430b4ac..0c6128e 100644 --- a/src/components/tickets/ticket-details-panel.tsx +++ b/src/components/tickets/ticket-details-panel.tsx @@ -1,19 +1,45 @@ import { useMemo } from "react" import { format } from "date-fns" import { ptBR } from "date-fns/locale" -import { IconAlertTriangle, IconClockHour4 } from "@tabler/icons-react" import type { TicketWithDetails } from "@/lib/schemas/ticket" import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" interface TicketDetailsPanelProps { ticket: TicketWithDetails } -const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700" -const iconAccentClass = "size-3 text-neutral-700" +type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger" + +const statusLabel: Record = { + PENDING: "Pendente", + AWAITING_ATTENDANCE: "Em andamento", + PAUSED: "Pausado", + RESOLVED: "Resolvido", +} + +const statusTone: Record = { + PENDING: "muted", + AWAITING_ATTENDANCE: "info", + PAUSED: "warning", + RESOLVED: "success", +} + +const priorityLabel: Record = { + LOW: "Baixa", + MEDIUM: "Média", + HIGH: "Alta", + URGENT: "Urgente", +} + +const priorityTone: Record = { + LOW: "muted", + MEDIUM: "info", + HIGH: "warning", + URGENT: "danger", +} function formatDuration(ms?: number | null) { if (!ms || ms <= 0) return "0s" @@ -30,9 +56,51 @@ function formatDuration(ms?: number | null) { return `${seconds}s` } +function formatMinutes(value?: number | null) { + if (value === null || value === undefined) return "—" + return `${value} min` +} + export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { const isAvulso = Boolean(ticket.company?.isAvulso) const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada") + + const summaryChips = useMemo( + () => [ + { + key: "queue", + label: "Fila", + value: ticket.queue ?? "Sem fila", + tone: ticket.queue ? ("default" as SummaryTone) : ("muted" as SummaryTone), + }, + { + key: "company", + label: "Empresa", + value: companyLabel, + tone: isAvulso ? ("warning" as SummaryTone) : ("default" as SummaryTone), + }, + { + key: "status", + label: "Status", + value: statusLabel[ticket.status] ?? ticket.status, + tone: statusTone[ticket.status] ?? "default", + }, + { + key: "priority", + label: "Prioridade", + value: priorityLabel[ticket.priority] ?? ticket.priority, + tone: priorityTone[ticket.priority] ?? "default", + }, + { + key: "assignee", + label: "Responsável", + value: ticket.assignee?.name ?? "Não atribuído", + tone: ticket.assignee ? ("default" as SummaryTone) : ("muted" as SummaryTone), + }, + ], + [companyLabel, isAvulso, ticket.assignee, ticket.priority, ticket.queue, ticket.status] + ) + const agentTotals = useMemo(() => { const totals = ticket.workSummary?.perAgentTotals ?? [] return totals @@ -49,90 +117,160 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { return ( - Detalhes +
+
+ Detalhes + + Resumo do ticket, métricas de SLA e tempo dedicado pela equipe. + +
+ {isAvulso ? ( + + Cliente avulso + + ) : null} +
- -
-

Fila

- {ticket.queue ?? "Sem fila"} -
- -
-

Empresa

- {companyLabel} -
- -
-

SLA

- {ticket.slaPolicy ? ( -
- {ticket.slaPolicy.name} -
- {ticket.slaPolicy.targetMinutesToFirstResponse ? ( - Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min - ) : null} - {ticket.slaPolicy.targetMinutesToResolution ? ( - Resolução: {ticket.slaPolicy.targetMinutesToResolution} min - ) : null} -
-
- ) : ( - Sem política atribuída. - )} -
- -
-

Métricas

- {ticket.metrics ? ( -
- - Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min - - - Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min - -
- ) : ( - Sem dados de SLA ainda. - )} -
- - {agentTotals.length > 0 ? ( - <> -
-

Tempo por agente

-
- {agentTotals.map((agent) => ( -
-
- {agent.agentName ?? "Agente removido"} - {formatDuration(agent.totalWorkedMs)} -
-
- Interno: {formatDuration(agent.internalWorkedMs)} - Externo: {formatDuration(agent.externalWorkedMs)} -
-
- ))} -
-
- - - ) : null} -
-

Histórico

-
- Criado: {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} - Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} - {ticket.resolvedAt ? ( - Resolvido: {format(ticket.resolvedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + +
+

Resumo

+
+ {summaryChips.map(({ key, label, value, tone }) => ( + + ))} +
+
+ +
+
+

SLA & métricas

+ {ticket.slaPolicy ? ( + {ticket.slaPolicy.name} ) : null}
-
+
+
+

Política de SLA

+ {ticket.slaPolicy ? ( +
+
+ Resposta inicial + + {formatMinutes(ticket.slaPolicy.targetMinutesToFirstResponse)} + +
+
+ Resolução + + {formatMinutes(ticket.slaPolicy.targetMinutesToResolution)} + +
+
+ ) : ( +

Sem política de SLA atribuída.

+ )} +
+
+

Métricas

+ {ticket.metrics ? ( +
+
+ Tempo aguardando + + {formatMinutes(ticket.metrics.timeWaitingMinutes)} + +
+
+ Tempo aberto + + {formatMinutes(ticket.metrics.timeOpenedMinutes)} + +
+
+ ) : ( +

Sem dados registrados ainda.

+ )} +
+
+ + +
+

Tempo por agente

+ {agentTotals.length > 0 ? ( +
+ {agentTotals.map((agent) => ( +
+
+ + {agent.agentName ?? "Agente removido"} + + + {formatDuration(agent.totalWorkedMs)} + +
+
+ + Interno:{" "} + {formatDuration(agent.internalWorkedMs)} + + + Externo:{" "} + {formatDuration(agent.externalWorkedMs)} + +
+
+ ))} +
+ ) : ( +

Nenhum tempo registrado neste ticket ainda.

+ )} +
+ +
+

Histórico

+
+
+ Criado em + + {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + +
+
+ Atualizado em + + {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + +
+
+ Resolvido em + + {ticket.resolvedAt ? format(ticket.resolvedAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "—"} + +
+
+
) -} +} + +function SummaryChip({ label, value, tone = "default" }: { label: string; value: string; tone?: SummaryTone }) { + const toneClasses: Record = { + default: "border-slate-200 bg-white text-neutral-900", + info: "border-sky-200 bg-sky-50 text-sky-900", + warning: "border-amber-200 bg-amber-50 text-amber-800", + success: "border-emerald-200 bg-emerald-50 text-emerald-800", + muted: "border-slate-200 bg-slate-50 text-neutral-600", + danger: "border-rose-200 bg-rose-50 text-rose-700", + } + + return ( +
+

{label}

+

{value}

+
+ ) +} diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 8bacaea..09890d0 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -157,7 +157,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { useQuery(queuesEnabled ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined ) ?? [] const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId) - const [status] = useState(ticket.status) const workSummaryRemote = useQuery( api.tickets.workSummary, convexUserId @@ -595,8 +594,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { sessionId?: Id<"ticketWorkSessions"> serverNow?: number } - const status = resultMeta?.status ?? "started" - if (status === "already_started") { + const startStatus = resultMeta?.status ?? "started" + if (startStatus === "already_started") { toast.info("O atendimento já estava em andamento", { id: "work" }) } else { toast.success("Atendimento iniciado", { id: "work" }) @@ -608,7 +607,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { typeof startedAtMsRaw === "number" && Number.isFinite(startedAtMsRaw) ? startedAtMsRaw : getServerNow() if (typeof startedAtMsRaw === "number") { localStartOriginRef.current = "remote" - } else if (status === "already_started") { + } else if (startStatus === "already_started") { localStartOriginRef.current = "already-running-fallback" } else { localStartOriginRef.current = "fresh-local"