feat: improve ticket export and navigation
This commit is contained in:
parent
0731c5d1ea
commit
7d6f3bea01
28 changed files with 1612 additions and 609 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
|
|
@ -25,33 +25,55 @@ function decodeMachineCookie(value: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
function encodeMachineCookie(payload: {
|
||||
machineId: string
|
||||
persona: string | null
|
||||
assignedUserId: string | null
|
||||
assignedUserEmail: string | null
|
||||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
}) {
|
||||
return Buffer.from(JSON.stringify(payload)).toString("base64url")
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session || session.user?.role !== "machine") {
|
||||
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value
|
||||
if (!cookieValue) {
|
||||
return NextResponse.json({ error: "Contexto da máquina ausente." }, { status: 404 })
|
||||
}
|
||||
|
||||
const decoded = decodeMachineCookie(cookieValue)
|
||||
if (!decoded?.machineId) {
|
||||
return NextResponse.json({ error: "Contexto da máquina inválido." }, { status: 400 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado." }, { status: 500 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null
|
||||
|
||||
const decoded = cookieValue ? decodeMachineCookie(cookieValue) : null
|
||||
let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null
|
||||
|
||||
if (!machineId) {
|
||||
try {
|
||||
const lookup = (await client.query(api.machines.findByAuthEmail, {
|
||||
authEmail: session.user.email.toLowerCase(),
|
||||
})) as { id: string } | null
|
||||
|
||||
if (!lookup?.id) {
|
||||
return NextResponse.json({ error: "Máquina não vinculada à sessão atual." }, { status: 404 })
|
||||
}
|
||||
|
||||
machineId = lookup.id as Id<"machines">
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao localizar máquina por e-mail", error)
|
||||
return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const context = (await client.query(api.machines.getContext, {
|
||||
machineId: decoded.machineId as Id<"machines">,
|
||||
machineId,
|
||||
})) as {
|
||||
id: string
|
||||
tenantId: string
|
||||
|
|
@ -66,10 +88,32 @@ export async function GET() {
|
|||
authEmail: string | null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
const responsePayload = {
|
||||
machineId: context.id,
|
||||
persona: context.persona,
|
||||
assignedUserId: context.assignedUserId,
|
||||
assignedUserEmail: context.assignedUserEmail,
|
||||
assignedUserName: context.assignedUserName,
|
||||
assignedUserRole: context.assignedUserRole,
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
machine: context,
|
||||
cookie: decoded,
|
||||
cookie: responsePayload,
|
||||
})
|
||||
|
||||
const isSecure = request.nextUrl.protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: MACHINE_CTX_COOKIE,
|
||||
value: encodeMachineCookie(responsePayload),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao obter contexto da máquina", error)
|
||||
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
|
||||
|
|
|
|||
|
|
@ -55,12 +55,13 @@ export async function POST(request: Request) {
|
|||
assignedUserName: session.machine.assignedUserName,
|
||||
assignedUserRole: session.machine.assignedUserRole,
|
||||
}
|
||||
const isSecure = new URL(request.url).protocol === "https:"
|
||||
response.cookies.set({
|
||||
name: "machine_ctx",
|
||||
value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"),
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
secure: isSecure,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import { NextResponse } from "next/server"
|
||||
// Use the standalone build to avoid AFM filesystem lookups
|
||||
// and ensure compatibility in serverless/traced environments.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore – no ambient types for this path; declared in types/
|
||||
import PDFDocument from "pdfkit/js/pdfkit.standalone.js"
|
||||
import { format } from "date-fns"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import fs from "fs"
|
||||
import { NextResponse } from "next/server"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -16,161 +9,19 @@ import { env } from "@/lib/env"
|
|||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
||||
import { renderTicketPdfBuffer } from "@/server/pdf/ticket-pdf-template"
|
||||
|
||||
// Force Node.js runtime for pdfkit compatibility
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "#64748B", // slate-500
|
||||
AWAITING_ATTENDANCE: "#0EA5E9", // sky-500
|
||||
PAUSED: "#F59E0B", // amber-500
|
||||
RESOLVED: "#10B981", // emerald-500
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
function formatDateTime(date: Date | null | undefined) {
|
||||
if (!date) return "—"
|
||||
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function htmlToPlainText(html?: string | null) {
|
||||
if (!html) return ""
|
||||
const withBreaks = html
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n\n")
|
||||
const stripped = withBreaks.replace(/<[^>]+>/g, "")
|
||||
return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim()
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(input: string) {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
}
|
||||
|
||||
function stringifyPayload(payload: unknown): string | null {
|
||||
if (!payload) return null
|
||||
if (typeof payload === "object") {
|
||||
if (Array.isArray(payload)) {
|
||||
if (payload.length === 0) return null
|
||||
} else if (payload) {
|
||||
if (Object.keys(payload as Record<string, unknown>).length === 0) return null
|
||||
}
|
||||
}
|
||||
if (typeof payload === "string" && payload.trim() === "") return null
|
||||
async function readLogoAsDataUrl() {
|
||||
const logoPath = path.join(process.cwd(), "public", "raven.png")
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2)
|
||||
} catch {
|
||||
return String(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number | null | undefined) {
|
||||
if (!ms || ms <= 0) return null
|
||||
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 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 = formatDurationMs((p.sessionDurationMs as number | undefined) ?? 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 (sessionDuration) parts.push(`Tempo registrado: ${sessionDuration}`)
|
||||
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
|
||||
const buffer = await fs.promises.readFile(logoPath)
|
||||
const base64 = buffer.toString("base64")
|
||||
return `data:image/png;base64,${base64}`
|
||||
} catch (error) {
|
||||
console.warn("[tickets.export.pdf] Logo não encontrado, seguindo sem imagem", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +39,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
let viewerId: string | null = null
|
||||
try {
|
||||
const ensuredUser = await client.mutation(api.users.ensureUser, {
|
||||
|
|
@ -199,8 +51,8 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
})
|
||||
viewerId = ensuredUser?._id ?? null
|
||||
} catch (error) {
|
||||
console.error("Failed to synchronize user with Convex for PDF export", error)
|
||||
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
|
||||
console.error("[tickets.export.pdf] Falha ao sincronizar usuário no Convex", error)
|
||||
return NextResponse.json({ error: "Não foi possível preparar a exportação" }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!viewerId) {
|
||||
|
|
@ -215,12 +67,8 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
viewerId: viewerId as unknown as Id<"users">,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load ticket from Convex for PDF export", error, {
|
||||
tenantId,
|
||||
ticketId,
|
||||
viewerId,
|
||||
})
|
||||
return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 })
|
||||
console.error("[tickets.export.pdf] Falha ao carregar ticket", error, { tenantId, ticketId, viewerId })
|
||||
return NextResponse.json({ error: "Não foi possível carregar o ticket" }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!ticketRaw) {
|
||||
|
|
@ -228,284 +76,20 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
}
|
||||
|
||||
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
|
||||
const doc = new PDFDocument({ size: "A4", margin: 56 })
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
doc.on("data", (chunk: unknown) => {
|
||||
const buf = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)
|
||||
chunks.push(buf)
|
||||
})
|
||||
|
||||
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)))
|
||||
doc.on("error", reject)
|
||||
})
|
||||
|
||||
// Register custom fonts (Inter) if available
|
||||
const logoDataUrl = await readLogoAsDataUrl()
|
||||
try {
|
||||
const pubRegular = path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf")
|
||||
const pubBold = path.join(process.cwd(), "public", "fonts", "Inter-Bold.ttf")
|
||||
const fontRegular = fs.existsSync(pubRegular)
|
||||
? pubRegular
|
||||
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf")
|
||||
const fontBold = fs.existsSync(pubBold)
|
||||
? pubBold
|
||||
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Bold.ttf")
|
||||
const D = doc as unknown as {
|
||||
registerFont?: (name: string, src: string) => void
|
||||
_fontFamilies?: Record<string, unknown>
|
||||
roundedRect?: (x: number, y: number, w: number, h: number, r: number) => void
|
||||
}
|
||||
if (fs.existsSync(fontRegular)) {
|
||||
D.registerFont?.("Inter", fontRegular)
|
||||
}
|
||||
if (fs.existsSync(fontBold)) {
|
||||
D.registerFont?.("Inter-Bold", fontBold)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const D = doc as unknown as { _fontFamilies?: Record<string, unknown>; roundedRect?: (x:number,y:number,w:number,h:number,r:number)=>void }
|
||||
const hasInter = Boolean(D._fontFamilies && (D._fontFamilies as Record<string, unknown>)["Inter-Bold"])
|
||||
|
||||
// Header with logo and brand bar
|
||||
try {
|
||||
const logoPath = path.join(process.cwd(), "public", "raven.png")
|
||||
if (fs.existsSync(logoPath)) {
|
||||
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
|
||||
}
|
||||
} catch {}
|
||||
doc.moveDown(0.5)
|
||||
doc
|
||||
.fillColor("#00e8ff")
|
||||
.rect(doc.page.margins.left, doc.y, doc.page.width - doc.page.margins.left - doc.page.margins.right, 3)
|
||||
.fill()
|
||||
doc.moveDown(0.5)
|
||||
|
||||
// Título
|
||||
doc.fillColor("#0F172A").font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`)
|
||||
doc.moveDown(0.25)
|
||||
// Linha abaixo do título
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
|
||||
// Badge de status
|
||||
doc.moveDown(0.5)
|
||||
const statusText = statusLabel[ticket.status] ?? ticket.status
|
||||
const badgeColor = statusColors[ticket.status] ?? "#475569"
|
||||
const badgeFontSize = 10
|
||||
const badgePaddingX = 6
|
||||
const badgePaddingY = 3
|
||||
const badgeX = doc.page.margins.left
|
||||
const badgeY = doc.y
|
||||
doc.save()
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(badgeFontSize)
|
||||
const badgeTextWidth = doc.widthOfString(statusText)
|
||||
const badgeHeight = badgeFontSize + badgePaddingY * 2
|
||||
const badgeWidth = badgeTextWidth + badgePaddingX * 2
|
||||
if (typeof D.roundedRect === "function") {
|
||||
D.roundedRect(badgeX, badgeY, badgeWidth, badgeHeight, 4)
|
||||
} else {
|
||||
doc.rect(badgeX, badgeY, badgeWidth, badgeHeight)
|
||||
}
|
||||
doc.fill(badgeColor)
|
||||
doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY)
|
||||
doc.restore()
|
||||
doc.y = badgeY + badgeHeight + 8
|
||||
|
||||
// Metadados em duas colunas
|
||||
const leftX = doc.page.margins.left
|
||||
const colGap = 24
|
||||
const colWidth = (doc.page.width - doc.page.margins.left - doc.page.margins.right - colGap) / 2
|
||||
const rightX = leftX + colWidth + colGap
|
||||
// const startY = doc.y
|
||||
const drawMeta = (x: number, lines: string[]) => {
|
||||
doc.save()
|
||||
doc.x = x
|
||||
doc.fillColor("#0F172A").font(hasInter ? "Inter" : "Helvetica").fontSize(11)
|
||||
for (const line of lines) {
|
||||
doc.text(line, { width: colWidth, lineGap: 2 })
|
||||
}
|
||||
const currY = doc.y
|
||||
doc.restore()
|
||||
return currY
|
||||
}
|
||||
const leftLines = [
|
||||
`Status: ${statusText}`,
|
||||
`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`,
|
||||
`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`,
|
||||
`Fila: ${ticket.queue ?? "—"}`,
|
||||
]
|
||||
const rightLines = [
|
||||
`Solicitante: ${ticket.requester.name} (${ticket.requester.email})`,
|
||||
`Responsável: ${ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído"}`,
|
||||
`Criado em: ${formatDateTime(ticket.createdAt)}`,
|
||||
`Atualizado em: ${formatDateTime(ticket.updatedAt)}`,
|
||||
]
|
||||
const leftY = drawMeta(leftX, leftLines)
|
||||
const rightY = drawMeta(rightX, rightLines)
|
||||
doc.y = Math.max(leftY, rightY)
|
||||
doc.moveDown(0.5)
|
||||
|
||||
doc.moveDown(0.75)
|
||||
doc
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text("Solicitante")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(`${ticket.requester.name} (${ticket.requester.email})`)
|
||||
|
||||
doc.moveDown(0.5)
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
|
||||
|
||||
doc.moveDown(0.75)
|
||||
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(11)
|
||||
.text(`Criado em: ${formatDateTime(ticket.createdAt)}`)
|
||||
.moveDown(0.15)
|
||||
.text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`)
|
||||
.moveDown(0.15)
|
||||
.text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`)
|
||||
|
||||
if (ticket.summary) {
|
||||
doc.moveDown(0.75)
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Resumo")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(ticket.summary, { align: "justify", lineGap: 2 })
|
||||
}
|
||||
|
||||
if (ticket.description) {
|
||||
doc.moveDown(0.75)
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Descrição")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 })
|
||||
}
|
||||
|
||||
if (ticket.comments.length > 0) {
|
||||
doc.addPage()
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Comentários")
|
||||
doc.moveDown(0.6)
|
||||
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
commentsSorted.forEach((comment, index) => {
|
||||
const visibility =
|
||||
comment.visibility === "PUBLIC" ? "Público" : "Interno"
|
||||
doc
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(11)
|
||||
.text(`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`)
|
||||
doc.moveDown(0.15)
|
||||
const body = htmlToPlainText(comment.body)
|
||||
if (body) {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(body, { align: "justify", lineGap: 2, indent: 6 })
|
||||
}
|
||||
if (comment.attachments.length > 0) {
|
||||
doc.moveDown(0.25)
|
||||
doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:")
|
||||
comment.attachments.forEach((attachment) => {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
|
||||
})
|
||||
}
|
||||
if (index < commentsSorted.length - 1) {
|
||||
doc.moveDown(1)
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.x, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.stroke()
|
||||
doc.moveDown(0.9)
|
||||
}
|
||||
const pdfBuffer = await renderTicketPdfBuffer({ ticket, logoDataUrl })
|
||||
const payload = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer)
|
||||
return new NextResponse(payload as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[tickets.export.pdf] Falha ao renderizar PDF", error)
|
||||
return NextResponse.json({ error: "Não foi possível gerar o PDF" }, { status: 500 })
|
||||
}
|
||||
|
||||
if (ticket.timeline.length > 0) {
|
||||
doc.addPage()
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Linha do tempo")
|
||||
doc.moveDown(0.6)
|
||||
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
timelineSorted.forEach((event) => {
|
||||
const label = TICKET_TIMELINE_LABELS[event.type] ?? event.type
|
||||
doc
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(11)
|
||||
.text(`${label} • ${formatDateTime(event.createdAt)}`)
|
||||
doc.moveDown(0.15)
|
||||
|
||||
const friendly = buildTimelineMessage(event.type, event.payload)
|
||||
if (friendly) {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(friendly, { indent: 16, lineGap: 1 })
|
||||
} else {
|
||||
const payloadText = stringifyPayload(event.payload)
|
||||
if (payloadText) {
|
||||
doc
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(payloadText, { indent: 16, lineGap: 1 })
|
||||
}
|
||||
}
|
||||
doc.moveDown(0.7)
|
||||
})
|
||||
}
|
||||
|
||||
doc.end()
|
||||
const pdfBuffer = await pdfBufferPromise
|
||||
const pdfBytes = new Uint8Array(pdfBuffer)
|
||||
|
||||
return new NextResponse(pdfBytes, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue