feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings

This commit is contained in:
Esdras Renan 2025-10-07 13:42:45 -03:00
parent addd4ce6e8
commit 3bafcc5a0a
45 changed files with 1401 additions and 256 deletions

View file

@ -0,0 +1,27 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const { id } = await params
const body = await request.json()
const updates: Record<string, any> = {}
for (const key of ["name", "slug", "cnpj", "domain", "phone", "description", "address"]) {
if (key in body) updates[key] = body[key] ?? null
}
if ("isAvulso" in body) updates.isAvulso = Boolean(body.isAvulso)
try {
const company = await prisma.company.update({ where: { id }, data: updates as any })
return NextResponse.json({ company })
} catch (error) {
console.error("Failed to update company", error)
return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 })
}
}

View file

@ -0,0 +1,47 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function GET() {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const companies = await prisma.company.findMany({
orderBy: { name: "asc" },
})
return NextResponse.json({ companies })
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const body = await request.json()
const { name, slug, isAvulso, cnpj, domain, phone, description, address } = body ?? {}
if (!name || !slug) {
return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 })
}
try {
const company = await prisma.company.create({
data: ({
tenantId: session.user.tenantId ?? "tenant-atlas",
name: String(name),
slug: String(slug),
isAvulso: Boolean(isAvulso ?? false),
cnpj: cnpj ? String(cnpj) : null,
domain: domain ? String(domain) : null,
phone: phone ? String(phone) : null,
description: description ? String(description) : null,
address: address ? String(address) : null,
} as any),
})
return NextResponse.json({ company })
} catch (error) {
console.error("Failed to create company", error)
return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 })
}
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { assertAdminSession } from "@/lib/auth-server"
import { api } from "@/convex/_generated/api"
export const runtime = "nodejs"
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const body = await request.json().catch(() => null) as { email?: string; companyId?: string }
const email = body?.email?.trim().toLowerCase()
const companyId = body?.companyId
if (!email || !companyId) {
return NextResponse.json({ error: "Informe e-mail e empresa" }, { status: 400 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
const client = new ConvexHttpClient(convexUrl)
try {
await client.mutation(api.users.assignCompany, {
tenantId: session.user.tenantId ?? "tenant-atlas",
email,
companyId: companyId as any,
actorId: (session.user as any).convexUserId ?? (session.user.id as any),
})
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Failed to assign company", error)
return NextResponse.json({ error: "Falha ao vincular usuário" }, { status: 500 })
}
}

View file

@ -0,0 +1,113 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
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 tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
let viewerId: string | null = null
try {
const ensuredUser = await client.mutation(api.users.ensureUser, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for backlog CSV", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
}
try {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const report = await client.query(api.reports.backlogOverview, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Backlog"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"])
rows.push([])
rows.push(["Seção", "Chave", "Valor"]) // header
// Status
const STATUS_PT: Record<string, string> = {
PENDING: "Pendentes",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausados",
RESOLVED: "Resolvidos",
}
for (const [status, total] of Object.entries(report.statusCounts)) {
rows.push(["Status", STATUS_PT[status] ?? status, total])
}
// Prioridade
const PRIORITY_PT: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Crítica",
}
for (const [priority, total] of Object.entries(report.priorityCounts)) {
rows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total])
}
// Filas
for (const q of report.queueCounts) {
rows.push(["Fila", q.name || q.id, q.total])
}
rows.push(["Abertos", "Total", report.totalOpen])
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate backlog CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV do backlog" }, { status: 500 })
}
}

View file

@ -0,0 +1,99 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
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, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for CSAT CSV", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
}
try {
const report = await client.query(api.reports.csatOverview, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "CSAT"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
rows.push(["CSAT médio", report.averageScore ?? "—"])
rows.push(["Total de respostas", report.totalSurveys ?? 0])
rows.push([])
rows.push(["Distribuição", "Total"])
for (const entry of report.distribution ?? []) {
rows.push([`Nota ${entry.score}`, entry.total])
}
rows.push([])
rows.push(["Recentes", "Nota", "Recebido em"])
for (const item of report.recent ?? []) {
const date = new Date(item.receivedAt).toISOString()
rows.push([`#${item.reference}`, item.score, date])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate CSAT CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 })
}
}

View file

@ -0,0 +1,101 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
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, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for SLA CSV", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
}
try {
const report = await client.query(api.reports.slaOverview, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "SLA e produtividade"])
rows.push(["Período", range ?? "—"])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
rows.push(["Tickets totais", report.totals.total])
rows.push(["Tickets abertos", report.totals.open])
rows.push(["Tickets resolvidos", report.totals.resolved])
rows.push(["Atrasados (SLA)", report.totals.overdue])
rows.push([])
rows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"])
rows.push(["Respostas registradas", report.response.responsesRegistered ?? 0])
rows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"])
rows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0])
rows.push([])
rows.push(["Fila", "Abertos"])
for (const q of report.queueBreakdown ?? []) {
rows.push([q.name || q.id, q.open])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="sla-${tenantId}.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate SLA CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 })
}
}

View file

@ -0,0 +1,102 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d)
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, {
tenantId,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for channel CSV", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
}
try {
const report = await client.query(api.reports.ticketsByChannel, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const channels = report.channels
const CHANNEL_PT: Record<string, string> = {
EMAIL: "E-mail",
PHONE: "Telefone",
CHAT: "Chat",
WHATSAPP: "WhatsApp",
API: "API",
MANUAL: "Manual",
WEB: "Portal",
PORTAL: "Portal",
}
const header = ["Data", ...channels.map((ch) => CHANNEL_PT[ch] ?? ch)]
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Tickets por canal"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
rows.push([])
rows.push(header)
for (const point of report.points) {
const values = channels.map((ch) => point.values[ch] ?? 0)
rows.push([point.date, ...values])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate tickets-by-channel CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV de tickets por canal" }, { status: 500 })
}
}

View file

@ -1,5 +1,9 @@
import { NextResponse } from "next/server"
import PDFDocument from "pdfkit"
// 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 { ptBR } from "date-fns/locale"
import { ConvexHttpClient } from "convex/browser"
@ -7,16 +11,44 @@ import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertStaffSession } from "@/lib/auth-server"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
// 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",
CLOSED: "Fechado",
}
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",
}
const timelineLabel: Record<string, string> = {
@ -31,6 +63,12 @@ const timelineLabel: Record<string, string> = {
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
CATEGORY_CHANGED: "Categoria alterada",
MANAGER_NOTIFIED: "Gestor notificado",
SUBJECT_CHANGED: "Assunto atualizado",
SUMMARY_CHANGED: "Resumo atualizado",
VISIT_SCHEDULED: "Visita agendada",
CSAT_RECEIVED: "CSAT recebido",
CSAT_RATED: "CSAT avaliado",
}
function formatDateTime(date: Date | null | undefined) {
@ -57,9 +95,104 @@ function decodeHtmlEntities(input: string) {
.replace(/&nbsp;/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
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: any): string | null {
if (!payload || typeof payload !== "object") payload = {}
const to = payload.toLabel ?? payload.to
const assignee = payload.assigneeName ?? payload.assigneeId
const queue = payload.queueName ?? payload.queueId
const requester = payload.requesterName
const author = payload.authorName ?? payload.authorId
const actor = payload.actorName ?? payload.actorId
const attachmentName = payload.attachmentName
const subjectTo = payload.to
const pauseReason = payload.pauseReasonLabel ?? payload.pauseReason
const pauseNote = payload.pauseNote
const sessionDuration = formatDurationMs(payload.sessionDurationMs)
const categoryName = payload.categoryName
const subcategoryName = payload.subcategoryName
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
}
}
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
const { id: ticketId } = await context.params
const session = await assertStaffSession()
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
@ -111,10 +244,10 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
}
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
const doc = new PDFDocument({ size: "A4", margin: 48 })
const doc = new PDFDocument({ size: "A4", margin: 56 })
const chunks: Buffer[] = []
doc.on("data", (chunk) => {
doc.on("data", (chunk: any) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
})
@ -123,24 +256,58 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.on("error", reject)
})
// Título
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.moveDown(0.5)
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("Helvetica-Bold").fontSize(badgeFontSize)
const badgeTextWidth = doc.widthOfString(statusText)
const badgeHeight = badgeFontSize + badgePaddingY * 2
const badgeWidth = badgeTextWidth + badgePaddingX * 2
;(doc as any).roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? 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 básicos
doc
.fillColor("#0F172A")
.font("Helvetica")
.fontSize(11)
.text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`)
.text(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Prioridade: ${ticket.priority}`)
.text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Canal: ${ticket.channel}`)
.moveDown(0.15)
.text(`Fila: ${ticket.queue ?? "—"}`)
.text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 })
doc.moveDown(0.75)
doc
.font("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("Helvetica")
.fontSize(11)
@ -148,6 +315,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.moveDown(0.5)
doc.font("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("Helvetica")
.fontSize(11)
@ -155,6 +328,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
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)
@ -167,25 +346,35 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
if (ticket.summary) {
doc.moveDown(0.75)
doc.font("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("Helvetica")
.fontSize(11)
.text(ticket.summary, { align: "justify" })
.text(ticket.summary, { align: "justify", lineGap: 2 })
}
if (ticket.description) {
doc.moveDown(0.75)
doc.font("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("Helvetica")
.fontSize(11)
.text(htmlToPlainText(ticket.description), { align: "justify" })
.text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 })
}
if (ticket.comments.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
doc.moveDown(0.5)
doc.moveDown(0.6)
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
commentsSorted.forEach((comment, index) => {
const visibility =
@ -193,15 +382,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc
.font("Helvetica-Bold")
.fontSize(11)
.text(
`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`
)
.text(`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`)
doc.moveDown(0.15)
const body = htmlToPlainText(comment.body)
if (body) {
doc
.font("Helvetica")
.fontSize(11)
.text(body, { align: "justify" })
.text(body, { align: "justify", lineGap: 2, indent: 6 })
}
if (comment.attachments.length > 0) {
doc.moveDown(0.25)
@ -210,17 +398,17 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc
.font("Helvetica")
.fontSize(10)
.text(`${attachment.name ?? attachment.id}`, { indent: 12 })
.text(`${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
})
}
if (index < commentsSorted.length - 1) {
doc.moveDown(0.75)
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.75)
doc.moveDown(0.9)
}
})
}
@ -228,7 +416,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
if (ticket.timeline.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
doc.moveDown(0.5)
doc.moveDown(0.6)
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
timelineSorted.forEach((event) => {
const label = timelineLabel[event.type] ?? event.type
@ -236,14 +424,24 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
.font("Helvetica-Bold")
.fontSize(11)
.text(`${label}${formatDateTime(event.createdAt)}`)
if (event.payload) {
const payloadText = JSON.stringify(event.payload, null, 2)
doc.moveDown(0.15)
const friendly = buildTimelineMessage(event.type, event.payload)
if (friendly) {
doc
.font("Helvetica")
.fontSize(10)
.text(payloadText, { indent: 12 })
.text(friendly, { indent: 16, lineGap: 1 })
} else {
const payloadText = stringifyPayload(event.payload)
if (payloadText) {
doc
.font("Helvetica")
.fontSize(10)
.text(payloadText, { indent: 16, lineGap: 1 })
}
}
doc.moveDown(0.5)
doc.moveDown(0.7)
})
}