diff --git a/src/server/pdf/ticket-pdf-template.tsx b/src/server/pdf/ticket-pdf-template.tsx
new file mode 100644
index 0000000..052cc1a
--- /dev/null
+++ b/src/server/pdf/ticket-pdf-template.tsx
@@ -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
= {
+ 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 = {
+ LOW: "Baixa",
+ MEDIUM: "Média",
+ HIGH: "Alta",
+ URGENT: "Urgente",
+ CRITICAL: "Crítica",
+}
+
+const channelLabel: Record = {
+ 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(/
/gi, "\n")
+ .replace(/<\/p>/gi, "\n\n")
+ .replace(/<[^>]+>/g, "")
+ .replace(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .trim()
+}
+
+function buildTimelineMessage(type: string, payload: Record | 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
+
+ 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
+
+ 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 (
+
+
+
+ {logoDataUrl ? : null}
+
+ Ticket #{ticket.reference}
+ {ticket.subject}
+
+
+
+ {status.label}
+
+
+
+
+
+
+
+ {leftMeta.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+ {rightMeta.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+
+
+ {ticket.summary ? (
+
+ Resumo
+ {ticket.summary}
+
+ ) : null}
+
+ {description ? (
+
+ Descrição
+ {description}
+
+ ) : null}
+
+ {ticket.tags && ticket.tags.length > 0 ? (
+
+ Etiquetas
+
+ {ticket.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ ) : null}
+
+ {comments.length > 0 ? (
+
+ Comentários
+ {comments.map((comment) => (
+
+
+
+ {comment.author.name}
+
+ {formatDateTime(comment.createdAt)} • {comment.visibility === "PUBLIC" ? "Público" : "Interno"}
+
+
+
+ {comment.safeBody}
+ {comment.attachments.length > 0 ? (
+
+ Anexos
+ {comment.attachments.map((attachment) => (
+
+ • {attachment.name ?? attachment.id}
+
+ ))}
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+ {timeline.length > 0 ? (
+
+ Linha do tempo
+ {timeline.map((event) => (
+
+ {event.label}
+ {formatDateTime(event.createdAt)}
+ {event.description ? (
+ {event.description}
+ ) : null}
+
+ ))}
+
+ ) : null}
+
+
+ )
+}
+
+export async function renderTicketPdfBuffer({
+ ticket,
+ logoDataUrl,
+}: {
+ ticket: TicketWithDetails
+ logoDataUrl?: string | null
+}) {
+ const doc =
+ const buffer = await renderToBuffer(doc)
+ return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
+}