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

@ -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 })

View file

@ -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,
})

View file

@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.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
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",
},
})
}

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -18,10 +18,10 @@ const jetBrainsMono = JetBrains_Mono({
})
export const metadata: Metadata = {
title: "Raven",
title: "Raven - Sistema de chamados",
description: "Plataforma Raven da Rever",
icons: {
icon: "/raven.png",
icon: "/icon.png",
},
}

View file

@ -67,12 +67,13 @@ export async function GET(request: NextRequest) {
assignedUserRole: session.machine.assignedUserRole,
}
const encodedContext = Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url")
const isSecure = redirectUrl.protocol === "https:"
response.cookies.set({
name: "machine_ctx",
value: encodedContext,
httpOnly: true,
sameSite: "lax",
secure: true,
secure: isSecure,
path: "/",
maxAge: 60 * 60 * 24 * 30,
})

View file

@ -0,0 +1,5 @@
import { TicketsResolvedPageClient } from "./tickets-resolved-page-client"
export default function TicketsResolvedPage() {
return <TicketsResolvedPageClient />
}

View file

@ -0,0 +1,31 @@
"use client"
import dynamic from "next/dynamic"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
const TicketsView = dynamic(
() =>
import("@/components/tickets/tickets-view").then((module) => ({
default: module.TicketsView,
})),
{ ssr: false }
)
export function TicketsResolvedPageClient() {
return (
<AppShell
header={
<SiteHeader
title="Tickets resolvidos"
lead="Histórico de atendimentos concluídos. Pesquise ou filtre por fila, empresa e canal."
/>
}
>
<div className="flex flex-col gap-6">
<TicketsView initialFilters={{ view: "completed" }} />
</div>
</AppShell>
)
}

View file

@ -55,6 +55,25 @@ type Props = {
defaultTenantId: string
}
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
machine: "Agente de máquina",
}
function formatRole(role: string) {
const key = role?.toLowerCase?.() ?? ""
return ROLE_LABELS[key] ?? role
}
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
if (!tenantId) return "Principal"
if (tenantId === defaultTenantId) return "Principal"
return tenantId
}
function formatDate(dateIso: string) {
const date = new Date(dateIso)
return new Intl.DateTimeFormat("pt-BR", {
@ -206,8 +225,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
</TabsList>
<TabsContent value="invites" className="mt-6 space-y-6">
@ -255,25 +272,23 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<SelectContent>
{normalizedRoles.map((item) => (
<SelectItem key={item} value={item}>
{item === "admin"
? "Administrador"
: item === "manager"
? "Gestor"
: item === "agent"
? "Agente"
: "Colaborador"}
{formatRole(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-tenant">Tenant</Label>
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
placeholder="ex.: principal"
/>
<p className="text-xs text-neutral-500">
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
</p>
</div>
<div className="grid gap-2">
<Label>Expira em</Label>
@ -377,7 +392,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Tenant</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th>
@ -392,8 +407,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<span className="text-xs text-neutral-500">{invite.email}</span>
</div>
</td>
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
@ -449,7 +464,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Tenant</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 font-medium">Criado em</th>
</tr>
</thead>
@ -458,8 +473,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
</tr>
))}
@ -476,27 +491,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
</Card>
</TabsContent>
<TabsContent value="queues" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de filas</CardTitle>
<CardDescription>
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
<TabsContent value="categories" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de categorias</CardTitle>
<CardDescription>
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
</Tabs>
)
}

View file

@ -18,11 +18,12 @@ import {
Layers3,
UserPlus,
BellRing,
ChevronDown,
} from "lucide-react"
import { usePathname } from "next/navigation"
import { SearchForm } from "@/components/search-form"
import { VersionSwitcher } from "@/components/version-switcher"
import { usePathname } from "next/navigation"
import { SearchForm } from "@/components/search-form"
import { VersionSwitcher } from "@/components/version-switcher"
import {
Sidebar,
SidebarContent,
@ -39,6 +40,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton"
import { NavUser } from "@/components/nav-user"
import { useAuth } from "@/lib/auth-client"
import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
@ -47,9 +49,10 @@ type NavRoleRequirement = "staff" | "admin"
type NavigationItem = {
title: string
url: string
icon: LucideIcon
icon?: LucideIcon
requiredRole?: NavRoleRequirement
exact?: boolean
children?: NavigationItem[]
}
type NavigationGroup = {
@ -65,7 +68,13 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
title: "Operação",
items: [
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" },
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
{
title: "Tickets",
url: "/tickets",
icon: Ticket,
requiredRole: "staff",
children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }],
},
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
],
@ -105,9 +114,34 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const pathname = usePathname()
const { session, isLoading, isAdmin, isStaff } = useAuth()
const [isHydrated, setIsHydrated] = React.useState(false)
const initialExpanded = React.useMemo(() => {
const open = new Set<string>()
navigation.navMain.forEach((group) => {
group.items.forEach((item) => {
if (!item.children || item.children.length === 0) return
const shouldOpen = item.children.some((child) => {
if (!canAccess(child.requiredRole)) return false
return pathname === child.url || pathname.startsWith(`${child.url}/`)
})
if (shouldOpen) {
open.add(item.title)
}
})
})
return open
}, [pathname])
const [expanded, setExpanded] = React.useState<Set<string>>(initialExpanded)
React.useEffect(() => {
setExpanded((prev) => {
const next = new Set(prev)
initialExpanded.forEach((key) => next.add(key))
return next
})
}, [initialExpanded])
React.useEffect(() => {
setIsHydrated(true)
@ -131,6 +165,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (requiredRole === "staff") return isStaff
return false
}
const toggleExpanded = React.useCallback((title: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(title)) {
next.delete(title)
} else {
next.add(title)
}
return next
})
}, [])
if (!isHydrated) {
return (
@ -180,16 +226,64 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item)}>
<a href={item.url} className="gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{visibleItems.map((item) => {
if (item.children && item.children.length > 0) {
const childItems = item.children.filter((child) => canAccess(child.requiredRole))
const isExpanded = expanded.has(item.title)
const isChildActive = childItems.some((child) => isActive(child))
const parentActive = isActive(item) || isChildActive
return (
<React.Fragment key={item.title}>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={parentActive}>
<a href={item.url} className={cn("gap-2", "relative pr-7") }>
{item.icon ? <item.icon className="size-4" /> : null}
<span className="flex-1">{item.title}</span>
<span
role="button"
aria-label={isExpanded ? "Recolher submenu" : "Expandir submenu"}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleExpanded(item.title)
}}
className={cn(
"absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-neutral-500 transition hover:bg-slate-200 hover:text-neutral-700",
isExpanded && "rotate-180"
)}
>
<ChevronDown className="size-3" />
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
{isExpanded
? childItems.map((child) => (
<SidebarMenuItem key={`${item.title}-${child.title}`}>
<SidebarMenuButton asChild isActive={isActive(child)}>
<a href={child.url} className="gap-2 pl-7 text-sm">
<span>{child.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))
: null}
</React.Fragment>
)
}
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item)}>
<a href={item.url} className="gap-2">
{item.icon ? <item.icon className="size-4" /> : null}
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View file

@ -26,9 +26,11 @@ export function PortalShell({ children }: PortalShellProps) {
const { session, machineContext } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const isMachineSession = session?.user.role === "machine"
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador"
const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador"
const initials = useMemo(() => {
const name = displayName || displayEmail || "Cliente"
@ -64,7 +66,7 @@ export function PortalShell({ children }: PortalShellProps) {
<GalleryVerticalEnd className="size-4" />
</span>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-[0.28em] text-neutral-500">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-neutral-500">
Portal do cliente
</span>
<span className="text-lg font-semibold text-neutral-900">Raven</span>
@ -100,12 +102,12 @@ export function PortalShell({ children }: PortalShellProps) {
<div className="flex flex-col leading-tight">
<span className="font-semibold text-neutral-900">{displayName}</span>
<span className="text-xs text-neutral-500">{displayEmail}</span>
{machineContext ? (
{personaValue ? (
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
) : null}
</div>
</div>
{!machineContext ? (
{!isMachineSession ? (
<Button
size="sm"
variant="outline"

View file

@ -31,11 +31,12 @@ function toHtml(text: string) {
export function PortalTicketForm() {
const router = useRouter()
const { convexUserId, session } = useAuth()
const { convexUserId, session, machineContext } = useAuth()
const createTicket = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("")
@ -51,7 +52,7 @@ export function PortalTicketForm() {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !isFormValid || isSubmitting) return
if (!viewerId || !isFormValid || isSubmitting) return
const trimmedSubject = subject.trim()
const trimmedSummary = summary.trim()
@ -66,14 +67,14 @@ export function PortalTicketForm() {
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
try {
const id = await createTicket({
actorId: convexUserId as Id<"users">,
actorId: viewerId,
tenantId,
subject: trimmedSubject,
summary: trimmedSummary || undefined,
priority: DEFAULT_PRIORITY,
channel: "MANUAL",
queueId: undefined,
requesterId: convexUserId as Id<"users">,
requesterId: viewerId,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
@ -89,7 +90,7 @@ export function PortalTicketForm() {
}))
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
authorId: viewerId,
visibility: "PUBLIC",
body: htmlBody,
attachments: typedAttachments,
@ -186,7 +187,7 @@ export function PortalTicketForm() {
</Button>
<Button
type="submit"
disabled={!isFormValid || isSubmitting}
disabled={!isFormValid || isSubmitting || !viewerId}
className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90"
>
Registrar chamado

View file

@ -16,14 +16,16 @@ import { Button } from "@/components/ui/button"
import { PortalTicketCard } from "@/components/portal/portal-ticket-card"
export function PortalTicketList() {
const { convexUserId, session } = useAuth()
const { convexUserId, session, machineContext } = useAuth()
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
viewerId
? {
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
viewerId,
limit: 100,
}
: "skip"
@ -34,7 +36,9 @@ export function PortalTicketList() {
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
}, [ticketsRaw])
if (ticketsRaw === undefined) {
const isLoading = Boolean(viewerId && ticketsRaw === undefined)
if (isLoading) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex items-center gap-2 px-5 py-5">
@ -48,7 +52,7 @@ export function PortalTicketList() {
)
}
if (!tickets.length) {
if (!viewerId || !tickets.length) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">

View file

@ -15,15 +15,17 @@ import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Spinner } from "@/components/ui/spinner"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
export function CommentTemplatesManager() {
const { convexUserId, session } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId as Id<"users"> | undefined
const [activeKind, setActiveKind] = useState<"comment" | "closing">("comment")
const templates = useQuery(
viewerId ? api.commentTemplates.list : "skip",
viewerId ? { tenantId, viewerId } : "skip"
viewerId ? { tenantId, viewerId, kind: activeKind } : "skip"
) as
| {
id: Id<"commentTemplates">
@ -33,6 +35,7 @@ export function CommentTemplatesManager() {
updatedAt: number
createdBy: Id<"users">
updatedBy: Id<"users"> | null
kind: "comment" | "closing" | string
}[]
| undefined
@ -48,6 +51,27 @@ export function CommentTemplatesManager() {
const orderedTemplates = useMemo(() => templates ?? [], [templates])
const kindLabels: Record<typeof activeKind, { title: string; description: string; placeholder: string; empty: { title: string; description: string } }> = {
comment: {
title: "Templates de comentário",
description: "Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.",
placeholder: "Escreva a mensagem padrão...",
empty: {
title: "Nenhum template cadastrado",
description: "Crie seu primeiro template de comentário usando o formulário acima.",
},
},
closing: {
title: "Templates de encerramento",
description: "Padronize as mensagens de fechamento de tickets. Os nomes dos clientes podem ser inseridos automaticamente com {{cliente}}.",
placeholder: "Conteúdo da mensagem de encerramento...",
empty: {
title: "Nenhum template de encerramento",
description: "Cadastre mensagens padrão para encerrar tickets rapidamente.",
},
},
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!viewerId) return
@ -64,7 +88,7 @@ export function CommentTemplatesManager() {
setIsSubmitting(true)
toast.loading("Criando template...", { id: "create-template" })
try {
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody })
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody, kind: activeKind })
toast.success("Template criado!", { id: "create-template" })
setTitle("")
setBody("")
@ -76,7 +100,7 @@ export function CommentTemplatesManager() {
}
}
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string) {
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string, kind: "comment" | "closing" | string) {
if (!viewerId) return
const trimmedTitle = nextTitle.trim()
const sanitizedBody = sanitizeEditorHtml(nextBody)
@ -97,6 +121,7 @@ export function CommentTemplatesManager() {
actorId: viewerId,
title: trimmedTitle,
body: sanitizedBody,
kind,
})
toast.success("Template atualizado!", { id: toastId })
return true
@ -134,13 +159,24 @@ export function CommentTemplatesManager() {
return (
<div className="space-y-6">
<Card className="border border-slate-200">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="text-xl font-semibold text-neutral-900">Templates de comentário</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.
</CardDescription>
<CardHeader className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl font-semibold text-neutral-900">Templates rápidos</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Gerencie mensagens padrão para comentários e encerramentos de tickets.
</CardDescription>
</div>
<Tabs value={activeKind} onValueChange={(value) => setActiveKind(value as "comment" | "closing")} className="w-full">
<TabsList className="h-10 w-full justify-start rounded-lg bg-slate-100 p-1">
<TabsTrigger value="comment" className="rounded-md px-4 py-1.5 text-sm font-medium">Comentários</TabsTrigger>
<TabsTrigger value="closing" className="rounded-md px-4 py-1.5 text-sm font-medium">Encerramentos</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent>
<CardDescription className="mb-4 text-sm text-neutral-600">
{kindLabels[activeKind].description}
</CardDescription>
<form className="space-y-4" onSubmit={handleCreate}>
<div className="space-y-2">
<label htmlFor="template-title" className="text-sm font-medium text-neutral-800">
@ -158,7 +194,7 @@ export function CommentTemplatesManager() {
<label htmlFor="template-body" className="text-sm font-medium text-neutral-800">
Conteúdo padrão
</label>
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder="Escreva a mensagem padrão..." />
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder={kindLabels[activeKind].placeholder} />
</div>
<div className="flex justify-end gap-2">
{body ? (
@ -189,7 +225,7 @@ export function CommentTemplatesManager() {
<IconFileText className="size-5 text-neutral-500" /> Templates cadastrados
</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Gerencie as mensagens prontas utilizadas nos comentários de tickets.
Gerencie as mensagens prontas utilizadas nos {activeKind === "comment" ? "comentários" : "encerramentos"} de tickets.
</CardDescription>
</CardHeader>
<CardContent>
@ -203,8 +239,8 @@ export function CommentTemplatesManager() {
<EmptyMedia variant="icon">
<IconFileText className="size-5 text-neutral-500" />
</EmptyMedia>
<EmptyTitle>Nenhum template cadastrado</EmptyTitle>
<EmptyDescription>Crie seu primeiro template usando o formulário acima.</EmptyDescription>
<EmptyTitle>{kindLabels[activeKind].empty.title}</EmptyTitle>
<EmptyDescription>{kindLabels[activeKind].empty.description}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
@ -231,8 +267,9 @@ type TemplateItemProps = {
title: string
body: string
updatedAt: number
kind: "comment" | "closing" | string
}
onSave: (templateId: Id<"commentTemplates">, title: string, body: string) => Promise<boolean | void>
onSave: (templateId: Id<"commentTemplates">, title: string, body: string, kind: "comment" | "closing" | string) => Promise<boolean | void>
onDelete: (templateId: Id<"commentTemplates">) => Promise<void>
}
@ -247,7 +284,7 @@ function TemplateItem({ template, onSave, onDelete }: TemplateItemProps) {
async function handleSave() {
setIsSaving(true)
const ok = await onSave(template.id, title, body)
const ok = await onSave(template.id, title, body, template.kind ?? "comment")
setIsSaving(false)
if (ok !== false) {
setIsEditing(false)

View file

@ -1,7 +1,7 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useMemo, useState } from "react"
import { useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -10,17 +10,28 @@ import { Button } from "@/components/ui/button"
import { AlertTriangle, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useAuth } from "@/lib/auth-client"
export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
const router = useRouter()
const remove = useMutation(api.tickets.remove)
const { convexUserId, isAdmin } = useAuth()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const viewerId = useMemo(() => (isAdmin && convexUserId ? (convexUserId as Id<"users">) : null), [isAdmin, convexUserId])
if (!viewerId) {
return null
}
async function confirm() {
setLoading(true)
toast.loading("Excluindo ticket...", { id: "del" })
try {
await remove({ ticketId })
if (!viewerId) {
throw new Error("missing actor")
}
await remove({ ticketId, actorId: viewerId })
toast.success("Ticket excluído.", { id: "del" })
setOpen(false)
router.push("/tickets")

View file

@ -1,15 +1,20 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { ChevronDown } from "lucide-react"
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
@ -32,44 +37,289 @@ const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hove
const baseBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
type ClosingTemplate = {
id: string
title: string
body: string
}
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
{
id: "default-standard",
title: "Encerramento padrão",
body: sanitizeEditorHtml(`
<p>Olá {{cliente}},</p>
<p>A equipe da Raven agradece o contato. Este ticket está sendo encerrado.</p>
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
`),
},
{
id: "default-no-contact",
title: "Tentativa de contato sem sucesso",
body: sanitizeEditorHtml(`
<p>Prezado(a) {{cliente}},</p>
<p>Realizamos uma tentativa de contato, mas não obtivemos sucesso.</p>
<p>Por favor, retorne assim que possível para seguirmos com as verificações necessárias.</p>
<p>Este ticket será encerrado após 3 tentativas realizadas sem sucesso.</p>
<p>Telefone para contato: <strong>${DEFAULT_PHONE_NUMBER}</strong>.</p>
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
`),
},
{
id: "default-closed-after-attempts",
title: "Encerramento após 3 tentativas",
body: sanitizeEditorHtml(`
<p>Prezado(a) {{cliente}},</p>
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
`),
},
]
function applyTemplatePlaceholders(html: string, customerName?: string | null) {
const normalizedName = customerName?.trim()
const fallback = normalizedName && normalizedName.length > 0 ? normalizedName : "cliente"
return html.replace(/{{\s*(cliente|customer|customername|nome|nomecliente)\s*}}/gi, fallback)
}
export function StatusSelect({
ticketId,
value,
tenantId,
requesterName,
}: {
ticketId: string
value: TicketStatus
tenantId: string
requesterName?: string | null
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value)
const { convexUserId } = useAuth()
const actorId = (convexUserId ?? null) as Id<"users"> | null
const [status, setStatus] = useState<TicketStatus>(value)
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
useEffect(() => {
setStatus(value)
}, [value])
const handleStatusChange = async (selected: string) => {
const next = selected as TicketStatus
if (next === "RESOLVED") {
setCloseDialogOpen(true)
return
}
const previous = status
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!actorId) {
throw new Error("missing user")
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch (error) {
console.error(error)
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}
return (
<Select
value={status}
onValueChange={async (selected) => {
const previous = status
const next = selected as TicketStatus
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!convexUserId) throw new Error("missing user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: convexUserId as Id<"users"> })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch {
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}}
>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))}
</SelectContent>
</Select>
<>
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))}
</SelectContent>
</Select>
<CloseTicketDialog
open={closeDialogOpen}
onOpenChange={(open) => {
if (!open) setCloseDialogOpen(false)
}}
ticketId={ticketId}
tenantId={tenantId}
actorId={actorId}
requesterName={requesterName}
onSuccess={() => {
setStatus("RESOLVED")
setCloseDialogOpen(false)
}}
/>
</>
)
}
type CloseTicketDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
ticketId: string
tenantId: string
actorId: Id<"users"> | null
requesterName?: string | null
onSuccess: () => void
}
function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, requesterName, onSuccess }: CloseTicketDialogProps) {
const updateStatus = useMutation(api.tickets.updateStatus)
const addComment = useMutation(api.tickets.addComment)
const closingTemplates = useQuery(
actorId && open ? api.commentTemplates.list : "skip",
actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : "skip"
) as { id: string; title: string; body: string }[] | undefined
const templatesLoading = Boolean(actorId && open && closingTemplates === undefined)
const templates = useMemo<ClosingTemplate[]>(() => {
if (closingTemplates && closingTemplates.length > 0) {
return closingTemplates.map((template) => ({ id: template.id, title: template.title, body: template.body }))
}
return DEFAULT_CLOSING_TEMPLATES
}, [closingTemplates])
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [message, setMessage] = useState<string>("")
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (!open) {
setSelectedTemplateId(null)
setMessage("")
setIsSubmitting(false)
return
}
if (templates.length > 0 && !selectedTemplateId && !message) {
const first = templates[0]
const hydrated = sanitizeEditorHtml(applyTemplatePlaceholders(first.body, requesterName))
setSelectedTemplateId(first.id)
setMessage(hydrated)
}
}, [open, templates, requesterName, selectedTemplateId, message])
const handleTemplateSelect = (template: ClosingTemplate) => {
setSelectedTemplateId(template.id)
const filled = sanitizeEditorHtml(applyTemplatePlaceholders(template.body, requesterName))
setMessage(filled)
}
const handleSubmit = async () => {
if (!actorId) {
toast.error("É necessário estar autenticado para encerrar o ticket.")
return
}
setIsSubmitting(true)
toast.loading("Encerrando ticket...", { id: "close-ticket" })
try {
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
const withPlaceholders = applyTemplatePlaceholders(message, requesterName)
const sanitized = sanitizeEditorHtml(withPlaceholders)
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
if (hasContent) {
await addComment({
ticketId: ticketId as unknown as Id<"tickets">,
authorId: actorId,
visibility: "PUBLIC",
body: sanitized,
attachments: [],
})
}
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
onOpenChange(false)
onSuccess()
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" })
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>
Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
{templatesLoading ? (
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Spinner className="size-4" /> Carregando templates...
</div>
) : (
<div className="flex flex-wrap gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant={selectedTemplateId === template.id ? "default" : "outline"}
size="sm"
onClick={() => handleTemplateSelect(template)}
>
{template.title}
</Button>
))}
</div>
)}
<p className="text-xs text-neutral-500">
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
<RichTextEditor
value={message}
onChange={setMessage}
minHeight={220}
placeholder="Escreva uma mensagem final para o cliente..."
/>
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
</div>
</div>
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
<div className="text-xs text-neutral-500">
O comentário será público e ficará registrado no histórico do ticket.
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setMessage("")
setSelectedTemplateId(null)
}}
disabled={isSubmitting}
>
Limpar mensagem
</Button>
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -51,7 +51,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const templateArgs = convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
: "skip"
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
| { id: string; title: string; body: string }[]

View file

@ -499,7 +499,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</Badge>
) : null}
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
<StatusSelect
ticketId={ticket.id}
value={status}
tenantId={ticket.tenantId}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
/>
{isPlaying ? (
<Button
size="sm"

View file

@ -84,12 +84,24 @@ interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
companies?: string[]
initialState?: Partial<TicketFiltersState>
}
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [], companies = [] }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
export function TicketsFilters({ onChange, queues = [], companies = [], initialState }: TicketsFiltersProps) {
const mergedDefaults = useMemo(
() => ({
...defaultTicketFilters,
...(initialState ?? {}),
}),
[initialState]
)
const [filters, setFilters] = useState<TicketFiltersState>(mergedDefaults)
useEffect(() => {
setFilters(mergedDefaults)
}, [mergedDefaults])
function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => ({ ...prev, ...partial }))

View file

@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -12,8 +12,23 @@ import { TicketsTable } from "@/components/tickets/tickets-table"
import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState>
}
export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
const mergedInitialFilters = useMemo(
() => ({
...defaultTicketFilters,
...(initialFilters ?? {}),
}),
[initialFilters]
)
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
useEffect(() => {
setFilters(mergedInitialFilters)
}, [mergedInitialFilters])
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -73,7 +88,12 @@ export function TicketsView() {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} companies={companies} />
<TicketsFilters
onChange={setFilters}
queues={(queues ?? []).map((q) => q.name)}
companies={companies}
initialState={mergedInitialFilters}
/>
{ticketsRaw === undefined ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">

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)
}