sistema-de-chamados/src/components/tickets/ticket-timeline.tsx

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>
)
}