Hours by client: add search and CSV filtering; add alerts cron (BRT 08:00 guard) + alerts panel filters; admin companies shows last alert; PDF Inter font from public/fonts; fix Select empty value; type cleanups; tests for CSV/TZ; remove Knowledge Base nav
This commit is contained in:
parent
2cf399dcb1
commit
08cc8037d5
151 changed files with 1404 additions and 214 deletions
127
src/app/api/admin/alerts/hours-usage/route.ts
Normal file
127
src/app/api/admin/alerts/hours-usage/route.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
import { env } from "@/lib/env"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendSmtpMail } from "@/server/email-smtp"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
function fmtHours(ms: number) {
|
||||
return (ms / 3600000).toFixed(2)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
if (!env.SMTP) return NextResponse.json({ error: "SMTP não configurado" }, { status: 500 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const range = searchParams.get("range") ?? "30d"
|
||||
const threshold = Number(searchParams.get("threshold") ?? 90)
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? "tenant-atlas"
|
||||
|
||||
// Ensure user exists in Convex to obtain a typed viewerId
|
||||
let viewerId: Id<"users"> | null = null
|
||||
try {
|
||||
const ensured = 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 = (ensured?._id ?? null) as Id<"users"> | null
|
||||
} catch (error) {
|
||||
console.error("Failed to synchronize user with Convex for alerts", 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 })
|
||||
|
||||
const report = await client.query(api.reports.hoursByClient, {
|
||||
tenantId,
|
||||
viewerId,
|
||||
range,
|
||||
})
|
||||
|
||||
type HoursByClientItem = {
|
||||
companyId: Id<"companies">
|
||||
name: string
|
||||
internalMs: number
|
||||
externalMs: number
|
||||
totalMs: number
|
||||
contractedHoursPerMonth: number | null
|
||||
}
|
||||
const items = (report.items ?? []) as HoursByClientItem[]
|
||||
const alerts = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= threshold)
|
||||
|
||||
for (const item of alerts) {
|
||||
// Find managers of the company in Prisma
|
||||
const managers = await prisma.user.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
companyId: item.companyId,
|
||||
role: "MANAGER",
|
||||
},
|
||||
select: { email: true, name: true },
|
||||
})
|
||||
if (managers.length === 0) continue
|
||||
|
||||
const subject = `Alerta: uso de horas em ${item.name} acima de ${threshold}%`
|
||||
const body = `
|
||||
<p>Olá,</p>
|
||||
<p>O uso de horas contratadas para <strong>${item.name}</strong> atingiu <strong>${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%</strong>.</p>
|
||||
<ul>
|
||||
<li>Horas internas: <strong>${fmtHours(item.internalMs)}</strong></li>
|
||||
<li>Horas externas: <strong>${fmtHours(item.externalMs)}</strong></li>
|
||||
<li>Total: <strong>${fmtHours(item.totalMs)}</strong></li>
|
||||
<li>Contratadas/mês: <strong>${item.contractedHoursPerMonth}</strong></li>
|
||||
</ul>
|
||||
<p>Reveja a alocação da equipe e, se necessário, ajuste o atendimento.</p>
|
||||
`
|
||||
let delivered = 0
|
||||
for (const m of managers) {
|
||||
try {
|
||||
await sendSmtpMail(
|
||||
{
|
||||
host: env.SMTP!.host,
|
||||
port: env.SMTP!.port,
|
||||
username: env.SMTP!.username,
|
||||
password: env.SMTP!.password,
|
||||
from: env.SMTP!.from!,
|
||||
},
|
||||
m.email,
|
||||
subject,
|
||||
body
|
||||
)
|
||||
delivered += 1
|
||||
} catch (error) {
|
||||
console.error("Failed to send alert to", m.email, error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.mutation(api.alerts.log, {
|
||||
tenantId,
|
||||
companyId: item.companyId,
|
||||
companyName: item.name,
|
||||
usagePct: (((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100),
|
||||
threshold,
|
||||
range,
|
||||
recipients: managers.map((m) => m.email),
|
||||
deliveredCount: delivered,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to log alert in Convex", error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ sent: alerts.length })
|
||||
}
|
||||
37
src/app/api/admin/companies/last-alerts/route.ts
Normal file
37
src/app/api/admin/companies/last-alerts/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await assertAdminSession()
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const slugsParam = searchParams.get("slugs")
|
||||
if (!slugsParam) return NextResponse.json({ items: {} })
|
||||
const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
const tenantId = session.user.tenantId ?? "tenant-atlas"
|
||||
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
|
||||
for (const slug of slugs) {
|
||||
try {
|
||||
const last = (await client.query(api.alerts.lastForCompanyBySlug, { tenantId, slug })) as
|
||||
| { createdAt: number; usagePct: number; threshold: number }
|
||||
| null
|
||||
result[slug] = last
|
||||
} catch {
|
||||
result[slug] = null
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: result })
|
||||
}
|
||||
|
|
@ -6,21 +6,10 @@ 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"
|
||||
import { rowsToCsv } from "@/lib/csv"
|
||||
|
||||
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) {
|
||||
|
|
@ -62,7 +51,7 @@ export async function GET(request: Request) {
|
|||
tenantId,
|
||||
viewerId: viewerId as unknown as Id<"users">,
|
||||
range,
|
||||
companyId: companyId as any,
|
||||
companyId: companyId as unknown as Id<"companies">,
|
||||
})
|
||||
|
||||
const rows: Array<Array<unknown>> = []
|
||||
|
|
|
|||
|
|
@ -6,21 +6,10 @@ 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"
|
||||
import { rowsToCsv } from "@/lib/csv"
|
||||
|
||||
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) {
|
||||
|
|
@ -34,6 +23,7 @@ export async function GET(request: Request) {
|
|||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const range = searchParams.get("range") ?? undefined
|
||||
const companyId = searchParams.get("companyId") ?? undefined
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
|
@ -62,11 +52,13 @@ export async function GET(request: Request) {
|
|||
tenantId,
|
||||
viewerId: viewerId as unknown as Id<"users">,
|
||||
range,
|
||||
companyId: companyId as unknown as Id<"companies">,
|
||||
})
|
||||
|
||||
const rows: Array<Array<unknown>> = []
|
||||
rows.push(["Relatório", "CSAT"])
|
||||
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
|
||||
if (companyId) rows.push(["EmpresaId", companyId])
|
||||
rows.push([])
|
||||
rows.push(["Métrica", "Valor"]) // header
|
||||
rows.push(["CSAT médio", report.averageScore ?? "—"])
|
||||
|
|
@ -96,4 +88,3 @@ export async function GET(request: Request) {
|
|||
return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,9 @@ 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"
|
||||
import { rowsToCsv } from "@/lib/csv"
|
||||
|
||||
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"
|
||||
}
|
||||
function msToHours(ms: number) {
|
||||
return (ms / 3600000).toFixed(2)
|
||||
}
|
||||
|
|
@ -29,6 +21,7 @@ export async function GET(request: Request) {
|
|||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const range = searchParams.get("range") ?? undefined
|
||||
const q = searchParams.get("q")?.toLowerCase().trim() ?? ""
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
|
@ -58,9 +51,11 @@ export async function GET(request: Request) {
|
|||
const rows: Array<Array<unknown>> = []
|
||||
rows.push(["Relatório", "Horas por cliente"])
|
||||
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
|
||||
if (q) rows.push(["Filtro", q])
|
||||
rows.push([])
|
||||
rows.push(["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"])
|
||||
for (const item of report.items) {
|
||||
const items = q ? report.items.filter((i: any) => String(i.name).toLowerCase().includes(q)) : report.items
|
||||
for (const item of items) {
|
||||
const internalH = msToHours(item.internalMs)
|
||||
const externalH = msToHours(item.externalMs)
|
||||
const totalH = msToHours(item.totalMs)
|
||||
|
|
@ -72,7 +67,7 @@ export async function GET(request: Request) {
|
|||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=UTF-8",
|
||||
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d.csv"`,
|
||||
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${q ? `-${encodeURIComponent(q)}` : ''}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
})
|
||||
|
|
@ -80,4 +75,3 @@ export async function GET(request: Request) {
|
|||
return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export async function GET(request: Request) {
|
|||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const range = searchParams.get("range") ?? undefined
|
||||
const companyId = searchParams.get("companyId") ?? undefined
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
|
@ -62,11 +63,13 @@ export async function GET(request: Request) {
|
|||
tenantId,
|
||||
viewerId: viewerId as unknown as Id<"users">,
|
||||
range,
|
||||
companyId: companyId as unknown as Id<"companies">,
|
||||
})
|
||||
|
||||
const rows: Array<Array<unknown>> = []
|
||||
rows.push(["Relatório", "SLA e produtividade"])
|
||||
rows.push(["Período", range ?? "—"])
|
||||
if (companyId) rows.push(["EmpresaId", companyId])
|
||||
rows.push([])
|
||||
|
||||
rows.push(["Métrica", "Valor"]) // header
|
||||
|
|
@ -98,4 +101,3 @@ export async function GET(request: Request) {
|
|||
return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,21 +6,10 @@ 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"
|
||||
import { rowsToCsv } from "@/lib/csv"
|
||||
|
||||
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) {
|
||||
|
|
@ -63,7 +52,7 @@ export async function GET(request: Request) {
|
|||
tenantId,
|
||||
viewerId: viewerId as unknown as Id<"users">,
|
||||
range,
|
||||
companyId: companyId as any,
|
||||
companyId: companyId as unknown as Id<"companies">,
|
||||
})
|
||||
|
||||
const channels = report.channels
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { NextResponse } from "next/server"
|
|||
// @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 { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
|
|
@ -123,21 +125,21 @@ function formatDurationMs(ms: number | null | undefined) {
|
|||
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
|
||||
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":
|
||||
|
|
@ -247,8 +249,9 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
const doc = new PDFDocument({ size: "A4", margin: 56 })
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
doc.on("data", (chunk: any) => {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
|
||||
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) => {
|
||||
|
|
@ -256,8 +259,48 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
doc.on("error", reject)
|
||||
})
|
||||
|
||||
// Register custom fonts (Inter) if available
|
||||
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", "rever-8.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.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`)
|
||||
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
|
||||
|
|
@ -276,30 +319,53 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
const badgeX = doc.page.margins.left
|
||||
const badgeY = doc.y
|
||||
doc.save()
|
||||
doc.font("Helvetica-Bold").fontSize(badgeFontSize)
|
||||
doc.font(hasInter ? "Inter-Bold" : "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)
|
||||
D.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(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 })
|
||||
.moveDown(0.15)
|
||||
.text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 })
|
||||
.moveDown(0.15)
|
||||
.text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 })
|
||||
// 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("Helvetica-Bold")
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text("Solicitante")
|
||||
doc
|
||||
|
|
@ -309,12 +375,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(`${ticket.requester.name} (${ticket.requester.email})`)
|
||||
|
||||
doc.moveDown(0.5)
|
||||
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
|
||||
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável")
|
||||
doc
|
||||
.strokeColor("#E2E8F0")
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
|
|
@ -322,7 +388,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
.stroke()
|
||||
doc.moveDown(0.3)
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
|
||||
|
||||
|
|
@ -345,58 +411,58 @@ 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.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("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.text(ticket.summary, { align: "justify", lineGap: 2 })
|
||||
}
|
||||
|
||||
if (ticket.description) {
|
||||
doc.moveDown(0.75)
|
||||
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
|
||||
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("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(11)
|
||||
.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.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("Helvetica-Bold")
|
||||
.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("Helvetica")
|
||||
.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("Helvetica").fontSize(10).text("Anexos:")
|
||||
doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:")
|
||||
comment.attachments.forEach((attachment) => {
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
|
||||
})
|
||||
|
|
@ -415,13 +481,13 @@ 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.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 = timelineLabel[event.type] ?? event.type
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
|
||||
.fontSize(11)
|
||||
.text(`${label} • ${formatDateTime(event.createdAt)}`)
|
||||
doc.moveDown(0.15)
|
||||
|
|
@ -429,14 +495,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
|||
const friendly = buildTimelineMessage(event.type, event.payload)
|
||||
if (friendly) {
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(friendly, { indent: 16, lineGap: 1 })
|
||||
} else {
|
||||
const payloadText = stringifyPayload(event.payload)
|
||||
if (payloadText) {
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.font(hasInter ? "Inter" : "Helvetica")
|
||||
.fontSize(10)
|
||||
.text(payloadText, { indent: 16, lineGap: 1 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue