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> = { 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 = 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 = [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 (

Nenhum evento registrado neste ticket ainda.

) } return (

Linha do tempo

Mostrando {rangeStart}-{rangeEnd} de {totalItems} eventos

{totalPages > 1 ? ( setPage((previous) => Math.max(1, previous - 1))} /> {paginationRange.map((item, index) => { if (typeof item === "number") { return ( { event.preventDefault() setPage(item) }} > {item} ) } return ( ) })} setPage((previous) => Math.min(totalPages, previous + 1))} /> ) : null}
{currentEvents.map((entry, index) => { const Icon = timelineIcons[entry.type] ?? IconClockHour4 const isLastGlobal = pageOffset + index === totalItems - 1 return (
{!isLastGlobal && ( )}
{timelineLabels[entry.type] ?? entry.type} {entry.payload?.actorName ? ( {String(entry.payload?.actorName ?? "").split(" ").slice(0, 2).map((part: string) => part[0]).join("").toUpperCase()} por {String(entry.payload?.actorName ?? "")} ) : null} {format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
{(() => { 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 = (
{labelPrefix}{" "} {linkedTicketId ? ( {linkContent} ) : ( {linkContent} )} {subject ? — {subject} : null}
) } 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 = (
Campos personalizados atualizados
    {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 (
  • {label}{" "} {previousFormatted} → {nextFormatted}
  • ) })}
) } else { const fieldLabels = payloadFields .map((field) => (typeof field?.label === "string" ? field.label.trim() : "")) .filter((label) => label.length > 0) message = (
Campos personalizados atualizados {fieldLabels.length > 0 ? ( ({fieldLabels.join(", ")}) ) : null}
) } } 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 = (
Responsável alterado {nextAssignee ? ( <> {" "} para {nextAssignee} ) : ( <>. )} {previousAssignee ? ( (antes: {previousAssignee}) ) : null} {reason ? (

{reason}

) : null}
) } 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 = (
{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"} {payload.pauseNote ? ( Observação: {payload.pauseNote} ) : null}
) } 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 = (
CSAT avaliado:{" "} {safeScore ?? "—"}/{safeMax}
{Array.from({ length: safeMax }).map((_, index) => ( ))}
{comment ? ( “{comment}” ) : null}
) } if (!message) return null return (
{message}
) })()}
) })}
) }