sistema-de-chamados/src/components/tickets/ticket-timeline.tsx
2025-11-07 23:59:16 -03:00

597 lines
27 KiB
TypeScript

import Link from "next/link"
import { useEffect, useMemo, useState, type ReactNode, type ComponentType } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import {
IconCalendar,
IconClockHour4,
IconFolders,
IconNote,
IconPaperclip,
IconSquareCheck,
IconStar,
IconUserCircle,
IconLink,
} 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 {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
import { formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
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,
REQUESTER_CHANGED: IconUserCircle,
MANAGER_NOTIFIED: IconUserCircle,
VISIT_SCHEDULED: IconCalendar,
CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar,
TICKET_LINKED: IconLink,
}
const timelineLabels: Record<string, string> = TICKET_TIMELINE_LABELS
interface TicketTimelineProps {
ticket: TicketWithDetails
}
const ITEMS_PER_PAGE = 10
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`
}
const [page, setPage] = useState(1)
const totalItems = ticket.timeline.length
const totalPages = Math.max(1, Math.ceil(totalItems / ITEMS_PER_PAGE))
const currentPage = Math.min(page, totalPages)
const pageOffset = (currentPage - 1) * ITEMS_PER_PAGE
const currentEvents = useMemo(
() => ticket.timeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
[pageOffset, ticket.timeline]
)
const paginationRange = useMemo(() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1)
}
const range: Array<number | "ellipsis-left" | "ellipsis-right"> = [1]
const left = Math.max(2, currentPage - 1)
const right = Math.min(totalPages - 1, currentPage + 1)
if (left > 2) {
range.push("ellipsis-left")
}
for (let i = left; i <= right; i += 1) {
range.push(i)
}
if (right < totalPages - 1) {
range.push("ellipsis-right")
}
range.push(totalPages)
return range
}, [currentPage, totalPages])
const rangeStart = totalItems === 0 ? 0 : pageOffset + 1
const rangeEnd = totalItems === 0 ? 0 : Math.min(pageOffset + ITEMS_PER_PAGE, totalItems)
useEffect(() => {
setPage(1)
}, [ticket.id])
useEffect(() => {
if (page > totalPages) {
setPage(totalPages)
}
}, [page, totalPages])
if (totalItems === 0) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="px-4 py-10">
<p className="text-center text-sm text-neutral-500">
Nenhum evento registrado neste ticket ainda.
</p>
</CardContent>
</Card>
)
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-6 px-4 pb-6">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 pb-4">
<div>
<h3 className="text-base font-semibold text-neutral-900">Linha do tempo</h3>
<p className="text-sm text-neutral-500">
Mostrando {rangeStart}-{rangeEnd} de {totalItems} eventos
</p>
</div>
{totalPages > 1 ? (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={currentPage === 1}
onClick={() => setPage((previous) => Math.max(1, previous - 1))}
/>
</PaginationItem>
{paginationRange.map((item, index) => {
if (typeof item === "number") {
return (
<PaginationItem key={`page-${item}`}>
<PaginationLink
href="#"
isActive={item === currentPage}
onClick={(event) => {
event.preventDefault()
setPage(item)
}}
>
{item}
</PaginationLink>
</PaginationItem>
)
}
return (
<PaginationItem key={`ellipsis-${item}-${index}`}>
<PaginationEllipsis />
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
disabled={currentPage === totalPages}
onClick={() => setPage((previous) => Math.min(totalPages, previous + 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
</div>
{currentEvents.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLastGlobal = pageOffset + index === totalItems - 1
return (
<div key={entry.id} className="relative pl-11">
{!isLastGlobal && (
<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 === "TICKET_LINKED") {
const payloadLink = payload as {
linkedTicketId?: string
linkedReference?: number | string | null
linkedSubject?: string | null
kind?: string | null
}
const linkedTicketId =
typeof payloadLink.linkedTicketId === "string" && payloadLink.linkedTicketId.trim().length > 0
? payloadLink.linkedTicketId.trim()
: null
const referenceValue = payloadLink.linkedReference
const referenceLabelRaw =
typeof referenceValue === "number"
? referenceValue.toString()
: typeof referenceValue === "string"
? referenceValue.trim()
: null
const referenceLabel = referenceLabelRaw && referenceLabelRaw.length > 0 ? referenceLabelRaw : null
const linkContent = referenceLabel ? `#${referenceLabel}` : "Ver ticket"
const subject =
typeof payloadLink.linkedSubject === "string" && payloadLink.linkedSubject.trim().length > 0
? payloadLink.linkedSubject.trim()
: null
const kindRaw =
typeof payloadLink.kind === "string" && payloadLink.kind.trim().length > 0
? payloadLink.kind.trim()
: "related"
const labelPrefix =
kindRaw === "resolution_parent"
? "Ticket vinculado a este chamado"
: kindRaw === "resolved_with"
? "Ticket resolvido com"
: "Ticket vinculado"
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">{labelPrefix}</span>{" "}
{linkedTicketId ? (
<Link
href={`/tickets/${linkedTicketId}`}
className="font-semibold text-primary transition hover:text-primary/80 hover:underline"
>
{linkContent}
</Link>
) : (
<span className="font-medium text-neutral-800">{linkContent}</span>
)}
{subject ? <span className="text-neutral-500"> {subject}</span> : null}
</span>
</div>
)
}
if (entry.type === "CUSTOM_FIELDS_UPDATED") {
type FieldPayload = {
fieldKey?: string
label?: string
type?: string
previousValue?: unknown
nextValue?: unknown
previousDisplayValue?: string | null
nextDisplayValue?: string | null
changeType?: string
}
const payloadFields = Array.isArray((payload as { fields?: unknown }).fields)
? ((payload as { fields?: FieldPayload[] }).fields ?? [])
: []
const hasValueDetails = payloadFields.some((field) => {
if (!field) return false
return (
Object.prototype.hasOwnProperty.call(field, "previousValue") ||
Object.prototype.hasOwnProperty.call(field, "nextValue") ||
Object.prototype.hasOwnProperty.call(field, "previousDisplayValue") ||
Object.prototype.hasOwnProperty.call(field, "nextDisplayValue")
)
})
if (hasValueDetails && payloadFields.length > 0) {
message = (
<div className="space-y-1">
<span className="block text-sm font-semibold text-neutral-800">
Campos personalizados atualizados
</span>
<ul className="mt-1 space-y-0.5 text-xs text-neutral-600">
{payloadFields.map((field, index) => {
const label =
typeof field?.label === "string" && field.label.trim().length > 0
? field.label.trim()
: `Campo ${index + 1}`
const baseType =
typeof field?.type === "string" && field.type.trim().length > 0
? field.type
: "text"
const previousFormatted = formatTicketCustomFieldValue({
type: baseType,
value: field?.previousValue,
displayValue:
typeof field?.previousDisplayValue === "string" && field.previousDisplayValue.trim().length > 0
? field.previousDisplayValue
: undefined,
})
const nextFormatted = formatTicketCustomFieldValue({
type: baseType,
value: field?.nextValue,
displayValue:
typeof field?.nextDisplayValue === "string" && field.nextDisplayValue.trim().length > 0
? field.nextDisplayValue
: undefined,
})
return (
<li key={`${entry.id}-${field?.fieldKey ?? label}`}>
<span className="font-semibold text-neutral-800">{label}</span>{" "}
<span className="text-neutral-500">
{previousFormatted} {nextFormatted}
</span>
</li>
)
})}
</ul>
</div>
)
} else {
const fieldLabels = payloadFields
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
.filter((label) => label.length > 0)
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Campos personalizados atualizados</span>
{fieldLabels.length > 0 ? (
<span className="text-neutral-500"> ({fieldLabels.join(", ")})</span>
) : null}
</span>
</div>
)
}
}
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
if (entry.type === "ASSIGNEE_CHANGED") {
const nextAssigneeRaw = payload.assigneeName ?? payload.assigneeId ?? null
const previousAssigneeRaw = (payload as { previousAssigneeName?: unknown }).previousAssigneeName ?? null
const nextAssignee =
typeof nextAssigneeRaw === "string" && nextAssigneeRaw.trim().length > 0
? nextAssigneeRaw.trim()
: typeof nextAssigneeRaw === "number"
? String(nextAssigneeRaw)
: null
const previousAssignee =
typeof previousAssigneeRaw === "string" && previousAssigneeRaw.trim().length > 0
? previousAssigneeRaw.trim()
: null
const reasonRaw = (payload as { reason?: unknown }).reason
const reason =
typeof reasonRaw === "string" && reasonRaw.trim().length > 0
? reasonRaw.trim()
: null
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Responsável alterado</span>
{nextAssignee ? (
<>
{" "}
para <span className="font-semibold text-neutral-900">{nextAssignee}</span>
</>
) : (
<>.</>
)}
{previousAssignee ? (
<span className="text-neutral-500"> (antes: {previousAssignee})</span>
) : null}
</span>
{reason ? (
<p className="whitespace-pre-line rounded-lg bg-slate-100 px-3 py-2 text-xs text-neutral-600">
{reason}
</p>
) : null}
</div>
)
}
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
}
if (entry.type === "REQUESTER_CHANGED") {
const payloadRequest = payload as {
requesterName?: string | null
requesterEmail?: string | null
requesterId?: string | null
companyName?: string | null
}
const name = payloadRequest.requesterName?.trim()
const email = payloadRequest.requesterEmail?.trim()
const fallback = payloadRequest.requesterId ?? null
const identifier = name ? (email ? `${name} · ${email}` : name) : email ?? fallback
const company = payloadRequest.companyName?.trim()
if (identifier && company) {
message = `Solicitante alterado para ${identifier} • Empresa: ${company}`
} else if (identifier) {
message = `Solicitante alterado para ${identifier}`
} else if (company) {
message = `Solicitante associado à empresa ${company}`
} else {
message = "Solicitante alterado"
}
}
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 rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
const rawScore =
typeof rawScoreSource.score === "number"
? rawScoreSource.score
: typeof rawScoreSource.rating === "number"
? rawScoreSource.rating
: null
const rawMaxSource = (payload as { maxScore?: unknown; max?: unknown }) ?? {}
const rawMax =
typeof rawMaxSource.maxScore === "number"
? rawMaxSource.maxScore
: typeof rawMaxSource.max === "number"
? rawMaxSource.max
: undefined
const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
const safeScore =
typeof rawScore === "number" && Number.isFinite(rawScore)
? Math.max(1, Math.min(safeMax, Math.round(rawScore)))
: null
const rawComment = (payload as { comment?: unknown })?.comment
const comment =
typeof rawComment === "string" && rawComment.trim().length > 0
? rawComment.trim()
: null
message = (
<div className="space-y-1">
<span>
CSAT avaliado:{" "}
<span className="font-semibold text-neutral-900">
{safeScore ?? "—"}/{safeMax}
</span>
</span>
<div className="flex items-center gap-1 text-amber-500">
{Array.from({ length: safeMax }).map((_, index) => (
<IconStar
key={index}
className="size-3.5"
strokeWidth={1.5}
fill={safeScore !== null && index < safeScore ? "currentColor" : "none"}
/>
))}
</div>
{comment ? (
<span className="block rounded-lg bg-slate-100 px-3 py-1 text-xs text-neutral-600">
{comment}
</span>
) : null}
</div>
)
}
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>
)
}