feat: improve ticket export and navigation

This commit is contained in:
Esdras Renan 2025-10-13 00:08:18 -03:00
parent 0731c5d1ea
commit 7d6f3bea01
28 changed files with 1612 additions and 609 deletions

View file

@ -0,0 +1,511 @@
/* eslint-disable jsx-a11y/alt-text */
import path from "path"
import fs from "fs"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import sanitizeHtml from "sanitize-html"
import {
Document,
Font,
Image,
Page,
StyleSheet,
Text,
View,
renderToBuffer,
} from "@react-pdf/renderer"
import type { TicketWithDetails, TicketComment, TicketEvent } from "../../lib/schemas/ticket"
import { TICKET_TIMELINE_LABELS } from "../../lib/ticket-timeline-labels"
Font.register({ family: "Inter", fonts: [] })
const interRegular = path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf")
const interSemiBold = path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-SemiBold.ttf")
const fallbackRegular = path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_18pt-Regular.ttf")
try {
if (fs.existsSync(interRegular)) {
Font.register({ family: "Inter", src: interRegular })
} else if (fs.existsSync(fallbackRegular)) {
Font.register({ family: "Inter", src: fallbackRegular })
}
if (fs.existsSync(interSemiBold)) {
Font.register({ family: "Inter-Semi", src: interSemiBold, fontWeight: 600 })
}
} catch (error) {
console.warn("[pdf] Não foi possível registrar fontes Inter, usando Helvetica", error)
}
const BASE_FONT = Font.getRegisteredFontFamilies().includes("Inter") ? "Inter" : "Helvetica"
const SEMI_FONT = Font.getRegisteredFontFamilies().includes("Inter-Semi") ? "Inter-Semi" : BASE_FONT
const styles = StyleSheet.create({
page: {
paddingTop: 32,
paddingBottom: 32,
paddingHorizontal: 36,
fontFamily: BASE_FONT,
backgroundColor: "#F8FAFC",
color: "#0F172A",
fontSize: 11,
},
header: {
backgroundColor: "#FFFFFF",
borderRadius: 12,
borderColor: "#E2E8F0",
borderWidth: 1,
padding: 18,
flexDirection: "row",
gap: 16,
alignItems: "center",
marginBottom: 18,
},
headerText: {
flex: 1,
gap: 4,
},
headerTitle: {
fontFamily: SEMI_FONT,
fontSize: 20,
color: "#0F172A",
},
headerSubtitle: {
fontSize: 10,
letterSpacing: 1,
textTransform: "uppercase",
color: "#64748B",
fontFamily: SEMI_FONT,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
fontSize: 10,
fontFamily: SEMI_FONT,
textTransform: "uppercase",
},
section: {
marginBottom: 20,
backgroundColor: "#FFFFFF",
borderRadius: 12,
borderColor: "#E2E8F0",
borderWidth: 1,
padding: 18,
},
sectionTitle: {
fontFamily: SEMI_FONT,
fontSize: 13,
color: "#1E293B",
marginBottom: 10,
},
metaGrid: {
flexDirection: "row",
gap: 18,
},
metaColumn: {
flex: 1,
gap: 8,
},
metaItem: {
gap: 4,
},
metaLabel: {
fontSize: 9,
letterSpacing: 1,
color: "#64748B",
textTransform: "uppercase",
fontFamily: SEMI_FONT,
},
metaValue: {
color: "#0F172A",
},
bodyText: {
color: "#334155",
lineHeight: 1.45,
},
chipList: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
marginTop: 6,
},
chip: {
backgroundColor: "#E2E8F0",
borderRadius: 999,
paddingHorizontal: 6,
paddingVertical: 4,
fontSize: 9,
fontFamily: SEMI_FONT,
color: "#475569",
},
card: {
borderColor: "#E2E8F0",
borderWidth: 1,
borderRadius: 12,
padding: 14,
marginBottom: 12,
backgroundColor: "#FFFFFF",
gap: 6,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
cardTitle: {
fontFamily: SEMI_FONT,
fontSize: 11,
color: "#0F172A",
},
cardSubtitle: {
color: "#64748B",
fontSize: 9,
},
cardFooterTitle: {
fontFamily: SEMI_FONT,
fontSize: 9,
marginTop: 6,
color: "#475569",
},
timelineCard: {
marginBottom: 10,
},
timelineDetails: {
fontSize: 10,
color: "#4B5563",
},
})
const statusStyles: Record<string, { backgroundColor: string; color: string; label: string }> = {
PENDING: { backgroundColor: "#F1F5F9", color: "#0F172A", label: "Pendente" },
AWAITING_ATTENDANCE: { backgroundColor: "#E0F2FE", color: "#0369A1", label: "Aguardando atendimento" },
PAUSED: { backgroundColor: "#FEF3C7", color: "#92400E", label: "Pausado" },
RESOLVED: { backgroundColor: "#DCFCE7", color: "#166534", label: "Resolvido" },
}
const priorityLabel: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
CRITICAL: "Crítica",
}
const channelLabel: Record<string, string> = {
EMAIL: "E-mail",
PHONE: "Telefone",
CHAT: "Chat",
PORTAL: "Portal",
WEB: "Portal",
API: "API",
SOCIAL: "Redes sociais",
OTHER: "Outro",
WHATSAPP: "WhatsApp",
MANUAL: "Manual",
}
const sanitizeOptions: sanitizeHtml.IOptions = {
allowedTags: ["p", "br", "strong", "em", "u", "s", "ul", "ol", "li", "blockquote"],
allowedAttributes: {},
selfClosing: ["br"],
allowVulnerableTags: false,
}
function formatDateTime(date: Date | null | undefined) {
if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
}
function sanitizeToPlainText(html?: string | null) {
if (!html) return ""
const stripped = sanitizeHtml(html, sanitizeOptions)
return stripped
.replace(/<br\s*\/>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim()
}
function buildTimelineMessage(type: string, payload: Record<string, unknown> | null | undefined): string | null {
const p = payload ?? {}
const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined)
const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined)
const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined)
const requester = p.requesterName as string | undefined
const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined)
const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined)
const attachmentName = p.attachmentName as string | undefined
const subjectTo = p.to as string | undefined
const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined)
const pauseNote = p.pauseNote as string | undefined
const sessionDuration = (p.sessionDurationMs as number | undefined) ?? null
const sessionText = sessionDuration ? formatDurationMs(sessionDuration) : null
const categoryName = p.categoryName as string | undefined
const subcategoryName = p.subcategoryName as string | undefined
switch (type) {
case "STATUS_CHANGED":
return to ? `Status alterado para ${to}` : "Status alterado"
case "ASSIGNEE_CHANGED":
return assignee ? `Responsável alterado para ${assignee}` : "Responsável alterado"
case "QUEUE_CHANGED":
return queue ? `Fila alterada para ${queue}` : "Fila alterada"
case "PRIORITY_CHANGED":
return to ? `Prioridade alterada para ${to}` : "Prioridade alterada"
case "CREATED":
return requester ? `Criado por ${requester}` : "Criado"
case "COMMENT_ADDED":
return author ? `Comentário adicionado por ${author}` : "Comentário adicionado"
case "COMMENT_EDITED": {
const who = actor ?? author
return who ? `Comentário editado por ${who}` : "Comentário editado"
}
case "SUBJECT_CHANGED":
return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado"
case "SUMMARY_CHANGED":
return "Resumo atualizado"
case "ATTACHMENT_REMOVED":
return attachmentName ? `Anexo removido: ${attachmentName}` : "Anexo removido"
case "WORK_PAUSED": {
const parts: string[] = []
if (pauseReason) parts.push(`Motivo: ${pauseReason}`)
if (sessionText) parts.push(`Tempo registrado: ${sessionText}`)
if (pauseNote) parts.push(`Observação: ${pauseNote}`)
return parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"
}
case "WORK_STARTED":
return "Atendimento iniciado"
case "CATEGORY_CHANGED": {
if (categoryName || subcategoryName) {
return `Categoria alterada para ${categoryName ?? ""}${subcategoryName ? `${subcategoryName}` : ""}`.trim()
}
return "Categoria removida"
}
case "MANAGER_NOTIFIED":
return "Gestor notificado"
case "VISIT_SCHEDULED":
return "Visita agendada"
case "CSAT_RECEIVED":
return "CSAT recebido"
case "CSAT_RATED":
return "CSAT avaliado"
default:
return null
}
}
function formatDurationMs(ms: number) {
if (!ms || ms <= 0) return "0s"
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`
return `${seconds}s`
}
function stringifyPayload(payload: unknown) {
if (!payload) return null
try {
return JSON.stringify(payload, null, 2)
} catch {
return String(payload)
}
}
const statusInfo = (status: string) => statusStyles[status] ?? statusStyles.PENDING
function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails; logoDataUrl?: string | null }) {
const status = statusInfo(ticket.status)
const requester = ticket.requester
const assignee = ticket.assignee
const comments = [...ticket.comments]
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((comment) => ({
...comment,
safeBody: sanitizeToPlainText(comment.body) || "Sem texto",
})) as Array<TicketComment & { safeBody: string }>
const timeline = [...ticket.timeline]
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((event) => {
const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type
const details = buildTimelineMessage(event.type, event.payload)
const raw = details ?? stringifyPayload(event.payload)
return {
...event,
label,
description: raw,
}
}) as Array<TicketEvent & { label: string; description: string | null }>
const leftMeta = [
{ label: "Status", value: status.label },
{ label: "Prioridade", value: priorityLabel[ticket.priority] ?? ticket.priority },
{ label: "Canal", value: channelLabel[ticket.channel] ?? ticket.channel ?? "—" },
{ label: "Fila", value: ticket.queue ?? "—" },
]
if (ticket.company?.name) {
leftMeta.push({ label: "Empresa", value: ticket.company.name })
}
if (ticket.category?.name) {
leftMeta.push({
label: "Categoria",
value: ticket.subcategory?.name ? `${ticket.category.name}${ticket.subcategory.name}` : ticket.category.name,
})
}
if (ticket.tags?.length) {
leftMeta.push({ label: "Tags", value: ticket.tags.join(", ") })
}
const rightMeta = [
{ label: "Solicitante", value: `${requester.name} (${requester.email})` },
{ label: "Responsável", value: assignee ? `${assignee.name} (${assignee.email})` : "Não atribuído" },
{ label: "Criado em", value: formatDateTime(ticket.createdAt) },
{ label: "Atualizado em", value: formatDateTime(ticket.updatedAt) },
]
if (ticket.resolvedAt) {
rightMeta.push({ label: "Resolvido em", value: formatDateTime(ticket.resolvedAt) })
}
const description = sanitizeToPlainText(ticket.description)
return (
<Document title={`Ticket #${ticket.reference}`}>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
{logoDataUrl ? <Image src={logoDataUrl} style={{ width: 72, height: 72, borderRadius: 12 }} /> : null}
<View style={styles.headerText}>
<Text style={styles.headerSubtitle}>Ticket #{ticket.reference}</Text>
<Text style={styles.headerTitle}>{ticket.subject}</Text>
</View>
<View style={{ paddingLeft: 6 }}>
<Text
style={{
...styles.statusBadge,
backgroundColor: status.backgroundColor,
color: status.color,
}}
>
{status.label}
</Text>
</View>
</View>
<View style={styles.section}>
<View style={styles.metaGrid}>
<View style={styles.metaColumn}>
{leftMeta.map((item) => (
<View key={item.label} style={styles.metaItem}>
<Text style={styles.metaLabel}>{item.label}</Text>
<Text style={styles.metaValue}>{item.value}</Text>
</View>
))}
</View>
<View style={styles.metaColumn}>
{rightMeta.map((item) => (
<View key={item.label} style={styles.metaItem}>
<Text style={styles.metaLabel}>{item.label}</Text>
<Text style={styles.metaValue}>{item.value}</Text>
</View>
))}
</View>
</View>
</View>
{ticket.summary ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Resumo</Text>
<Text style={styles.bodyText}>{ticket.summary}</Text>
</View>
) : null}
{description ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Descrição</Text>
<Text style={styles.bodyText}>{description}</Text>
</View>
) : null}
{ticket.tags && ticket.tags.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Etiquetas</Text>
<View style={styles.chipList}>
{ticket.tags.map((tag) => (
<Text key={tag} style={styles.chip}>
{tag}
</Text>
))}
</View>
</View>
) : null}
{comments.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Comentários</Text>
{comments.map((comment) => (
<View key={comment.id} style={styles.card} wrap={false}>
<View style={styles.cardHeader}>
<View>
<Text style={styles.cardTitle}>{comment.author.name}</Text>
<Text style={styles.cardSubtitle}>
{formatDateTime(comment.createdAt)} {comment.visibility === "PUBLIC" ? "Público" : "Interno"}
</Text>
</View>
</View>
<Text style={styles.bodyText}>{comment.safeBody}</Text>
{comment.attachments.length > 0 ? (
<View>
<Text style={styles.cardFooterTitle}>Anexos</Text>
{comment.attachments.map((attachment) => (
<Text key={attachment.id} style={{ fontSize: 9, color: "#475569" }}>
{attachment.name ?? attachment.id}
</Text>
))}
</View>
) : null}
</View>
))}
</View>
) : null}
{timeline.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Linha do tempo</Text>
{timeline.map((event) => (
<View key={event.id} style={[styles.card, styles.timelineCard]} wrap={false}>
<Text style={styles.cardTitle}>{event.label}</Text>
<Text style={styles.cardSubtitle}>{formatDateTime(event.createdAt)}</Text>
{event.description ? (
<Text style={styles.timelineDetails}>{event.description}</Text>
) : null}
</View>
))}
</View>
) : null}
</Page>
</Document>
)
}
export async function renderTicketPdfBuffer({
ticket,
logoDataUrl,
}: {
ticket: TicketWithDetails
logoDataUrl?: string | null
}) {
const doc = <TicketPdfDocument ticket={ticket} logoDataUrl={logoDataUrl} />
const buffer = await renderToBuffer(doc)
return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
}