feat: improve ticket export and navigation
This commit is contained in:
parent
0731c5d1ea
commit
7d6f3bea01
28 changed files with 1612 additions and 609 deletions
511
src/server/pdf/ticket-pdf-template.tsx
Normal file
511
src/server/pdf/ticket-pdf-template.tsx
Normal 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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue