239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
import { format } from "date-fns"
|
|
import type { ComponentType, ReactNode } from "react"
|
|
import { ptBR } from "date-fns/locale"
|
|
import {
|
|
IconCalendar,
|
|
IconClockHour4,
|
|
IconFolders,
|
|
IconNote,
|
|
IconPaperclip,
|
|
IconSquareCheck,
|
|
IconStar,
|
|
IconUserCircle,
|
|
} from "@tabler/icons-react"
|
|
|
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
|
|
|
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
|
CREATED: IconUserCircle,
|
|
STATUS_CHANGED: IconSquareCheck,
|
|
ASSIGNEE_CHANGED: IconUserCircle,
|
|
COMMENT_ADDED: IconNote,
|
|
COMMENT_EDITED: IconNote,
|
|
WORK_STARTED: IconClockHour4,
|
|
WORK_PAUSED: IconClockHour4,
|
|
SUBJECT_CHANGED: IconNote,
|
|
SUMMARY_CHANGED: IconNote,
|
|
QUEUE_CHANGED: IconSquareCheck,
|
|
PRIORITY_CHANGED: IconSquareCheck,
|
|
ATTACHMENT_REMOVED: IconPaperclip,
|
|
CATEGORY_CHANGED: IconFolders,
|
|
MANAGER_NOTIFIED: IconUserCircle,
|
|
VISIT_SCHEDULED: IconCalendar,
|
|
CSAT_RECEIVED: IconStar,
|
|
CSAT_RATED: IconStar,
|
|
}
|
|
|
|
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
|
|
|
|
interface TicketTimelineProps {
|
|
ticket: TicketWithDetails
|
|
}
|
|
|
|
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|
const formatDuration = (durationMs: number) => {
|
|
if (!durationMs || durationMs <= 0) return "0s"
|
|
const totalSeconds = Math.floor(durationMs / 1000)
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
const seconds = totalSeconds % 60
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
|
}
|
|
return `${seconds}s`
|
|
}
|
|
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardContent className="space-y-5 px-4 pb-6">
|
|
{ticket.timeline.map((entry, index) => {
|
|
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
|
const isLast = index === ticket.timeline.length - 1
|
|
return (
|
|
<div key={entry.id} className="relative pl-11">
|
|
{!isLast && (
|
|
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
|
|
)}
|
|
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span className="text-sm font-semibold text-neutral-900">
|
|
{timelineLabels[entry.type] ?? entry.type}
|
|
</span>
|
|
{entry.payload?.actorName ? (
|
|
<span className="flex items-center gap-1 text-xs text-neutral-500">
|
|
<Avatar className="size-5 border border-slate-200">
|
|
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
|
|
<AvatarFallback>
|
|
{String(entry.payload?.actorName ?? "").split(" ").slice(0, 2).map((part: string) => part[0]).join("").toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
por {String(entry.payload?.actorName ?? "")}
|
|
</span>
|
|
) : null}
|
|
<span className="text-xs text-neutral-500">
|
|
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
|
|
</span>
|
|
</div>
|
|
{(() => {
|
|
const payload = (entry.payload || {}) as {
|
|
toLabel?: string
|
|
to?: string
|
|
assigneeName?: string
|
|
assigneeId?: string
|
|
queueName?: string
|
|
queueId?: string
|
|
requesterName?: string
|
|
authorName?: string
|
|
authorId?: string
|
|
actorName?: string
|
|
actorId?: string
|
|
from?: string
|
|
attachmentName?: string
|
|
sessionDurationMs?: number
|
|
categoryName?: string
|
|
subcategoryName?: string
|
|
pauseReason?: string
|
|
pauseReasonLabel?: string
|
|
pauseNote?: string
|
|
managerName?: string
|
|
managerUserName?: string
|
|
manager?: string
|
|
managerId?: string
|
|
managerUserId?: string
|
|
scheduledFor?: number | string
|
|
scheduledAt?: number | string
|
|
score?: number
|
|
rating?: number
|
|
maxScore?: number
|
|
max?: number
|
|
}
|
|
|
|
let message: ReactNode = null
|
|
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
|
message = "Status alterado para " + (payload.toLabel || payload.to)
|
|
}
|
|
if (entry.type === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
|
|
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
|
|
}
|
|
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
|
|
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
|
|
}
|
|
if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) {
|
|
message = "Prioridade alterada para " + (payload.toLabel || payload.to)
|
|
}
|
|
if (entry.type === "CREATED" && payload.requesterName) {
|
|
message = "Criado por " + payload.requesterName
|
|
}
|
|
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
|
|
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
|
|
}
|
|
if (entry.type === "COMMENT_EDITED" && (payload.actorName || payload.actorId || payload.authorName)) {
|
|
const name = payload.actorName ?? payload.authorName
|
|
message = "Comentário editado" + (name ? " por " + name : "")
|
|
}
|
|
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
|
|
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
|
|
}
|
|
if (entry.type === "SUMMARY_CHANGED") {
|
|
message = "Resumo atualizado"
|
|
}
|
|
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
|
|
message = `Anexo removido: ${payload.attachmentName}`
|
|
}
|
|
if (entry.type === "WORK_PAUSED") {
|
|
const parts: string[] = []
|
|
if (payload.pauseReasonLabel || payload.pauseReason) {
|
|
parts.push(`Motivo: ${payload.pauseReasonLabel ?? payload.pauseReason}`)
|
|
}
|
|
if (typeof payload.sessionDurationMs === "number") {
|
|
parts.push(`Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`)
|
|
}
|
|
message = (
|
|
<div className="space-y-1">
|
|
<span>{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"}</span>
|
|
{payload.pauseNote ? (
|
|
<span className="block text-xs text-neutral-500">Observação: {payload.pauseNote}</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
if (entry.type === "CATEGORY_CHANGED") {
|
|
if (payload.categoryName || payload.subcategoryName) {
|
|
message = `Categoria alterada para ${payload.categoryName ?? ""}${
|
|
payload.subcategoryName ? ` • ${payload.subcategoryName}` : ""
|
|
}`
|
|
} else {
|
|
message = "Categoria removida"
|
|
}
|
|
}
|
|
if (entry.type === "MANAGER_NOTIFIED") {
|
|
const manager =
|
|
payload.managerName ??
|
|
payload.managerUserName ??
|
|
payload.manager ??
|
|
payload.managerId ??
|
|
payload.managerUserId
|
|
message = manager ? `Gestor notificado: ${manager}` : "Gestor notificado"
|
|
}
|
|
if (entry.type === "VISIT_SCHEDULED") {
|
|
const scheduledRaw = payload.scheduledFor ?? payload.scheduledAt
|
|
let formatted: string | null = null
|
|
if (typeof scheduledRaw === "number" || typeof scheduledRaw === "string") {
|
|
const date = new Date(scheduledRaw)
|
|
if (!Number.isNaN(date.getTime())) {
|
|
formatted = format(date, "dd MMM yyyy HH:mm", { locale: ptBR })
|
|
}
|
|
}
|
|
message = formatted ? `Visita agendada para ${formatted}` : "Visita agendada"
|
|
}
|
|
if (entry.type === "CSAT_RECEIVED") {
|
|
message = "CSAT recebido"
|
|
}
|
|
if (entry.type === "CSAT_RATED") {
|
|
const score = typeof payload.score === "number" ? payload.score : payload.rating
|
|
const maxScore =
|
|
typeof payload.maxScore === "number"
|
|
? payload.maxScore
|
|
: typeof payload.max === "number"
|
|
? payload.max
|
|
: undefined
|
|
message =
|
|
typeof score === "number"
|
|
? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}`
|
|
: "CSAT avaliado"
|
|
}
|
|
if (!message) return null
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">{message}</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
<Separator className="bg-slate-200" />
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|